-
Notifications
You must be signed in to change notification settings - Fork 14
/
analyzer.ts
349 lines (295 loc) · 9.38 KB
/
analyzer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
import {descendingOverThresholds} from './utils';
import type {
Peaks,
PeaksAndThreshold,
BpmCandidates,
Interval,
Tempo,
Threshold,
AnalyzerFindPeaksOptions,
AnalyzerGroupByTempoOptions,
AnalyzerComputeBpmOptions,
BiquadFilterOptions,
AnalyzerFindPeaksAtTheshold,
} from './types';
import * as consts from './consts';
import * as utils from './utils';
/**
* Find peaks when the signal if greater than the threshold, then move 10_000 indexes (represents ~0.23s) to ignore the descending phase of the parabol
* @param options - AnalyzerFindPeaksAtTheshold
* @param options.audioSampleRate - Sample rate
* @param options.data - Buffer channel data
* @param options.threshold - Threshold for qualifying as a peak
* @param options.offset - Position where we start to loop
* @returns Peaks found that are greater than the threshold
*/
export function findPeaksAtThreshold({
audioSampleRate,
data,
threshold,
offset = 0,
}: AnalyzerFindPeaksAtTheshold): PeaksAndThreshold {
const peaks: Peaks = [];
const skipForwardIndexes = utils.computeIndexesToSkip(0.25, audioSampleRate);
const {length} = data;
/**
* Identify peaks that are greater than the threshold, adding them to the collection
*/
for (let i = offset; i < length; i += 1) {
if (data[i] > threshold) {
peaks.push(i);
/**
* Skip forward ~0.25s to pass this peak
*/
i += skipForwardIndexes;
}
}
return {
peaks,
threshold,
};
}
/**
* Find the minimum amount of peaks from top to bottom threshold, it's necessary to analyze at least 10seconds at 90bpm
* @param options - AnalyzerFindPeaksOptions
* @param options.audioSampleRate - Sample rate
* @param options.channelData - Channel data
* @returns Suffisent amount of peaks in order to continue further the process
*/
export async function findPeaks({
audioSampleRate,
channelData,
}: AnalyzerFindPeaksOptions): Promise<PeaksAndThreshold> {
let validPeaks: Peaks = [];
let validThreshold = 0;
await descendingOverThresholds(async threshold => {
const {peaks} = findPeaksAtThreshold({audioSampleRate, data: channelData, threshold});
/**
* Loop over peaks
*/
if (peaks.length < consts.minPeaks) {
return false;
}
validPeaks = peaks;
validThreshold = threshold;
return true;
});
return {
peaks: validPeaks,
threshold: validThreshold,
};
}
/**
* Helpfull function to create standard and shared lowpass and highpass filters
* Important Note: The original library wasn't using properly the lowpass filter and it was not applied at all.
* This method should not be used unitl more research and documented tests will be acheived.
* @param context - AudioContext instance
* @param options - Optionnal BiquadFilterOptions
* @returns BiquadFilterNode
*/
export function getBiquadFilter(context: OfflineAudioContext | AudioContext, options?: BiquadFilterOptions): BiquadFilterNode {
const lowpass = context.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = options?.frequencyValue ?? consts.frequencyValue;
lowpass.Q.value = options?.qualityValue ?? consts.qualityValue;
return lowpass;
}
/**
* Apply to the source a biquad lowpass filter
* @param buffer - Audio buffer
* @param options - Optionnal BiquadFilterOptions
* @returns A Promise that resolves an AudioBuffer instance
*/
export async function getOfflineLowPassSource(buffer: AudioBuffer, options?: BiquadFilterOptions): Promise<AudioBuffer> {
const {length, numberOfChannels, sampleRate} = buffer;
const offlineAudioContext = new OfflineAudioContext(numberOfChannels, length, sampleRate);
/**
* Create buffer source
*/
const source = offlineAudioContext.createBufferSource();
source.buffer = buffer;
const lowpass = getBiquadFilter(offlineAudioContext, options);
/**
* Pipe the song into the filter, and the filter into the offline context
*/
source.connect(lowpass);
lowpass.connect(offlineAudioContext.destination);
source.start(0);
const audioBuffer = await offlineAudioContext.startRendering();
return audioBuffer;
}
/**
* Return the computed bpm from data
* @param options - AnalyzerComputeBpmOptions
* @param options.data - Contain valid peaks
* @param options.audioSampleRate - Audio sample rate
* @returns A Promise that resolves BPM Candidates
*/
export async function computeBpm({
audioSampleRate,
data,
}: AnalyzerComputeBpmOptions): Promise<BpmCandidates> {
const minPeaks = consts.minPeaks;
/**
* Flag to fix Object.keys looping
*/
let hasPeaks = false;
let foundThreshold = consts.minValidThreshold;
await descendingOverThresholds(async (threshold: Threshold) => {
if (hasPeaks) {
return true;
}
if (data[threshold].length > minPeaks) {
hasPeaks = true;
foundThreshold = threshold;
}
return false;
});
if (hasPeaks && foundThreshold) {
const intervals = identifyIntervals(data[foundThreshold]);
const tempos = groupByTempo({audioSampleRate, intervalCounts: intervals});
const candidates = getTopCandidates(tempos);
const bpmCandidates: BpmCandidates = {
bpm: candidates,
threshold: foundThreshold,
};
return bpmCandidates;
}
return {
bpm: [],
threshold: foundThreshold,
};
}
/**
* Sort results by count and return top candidate
* @param candidates - BPMs with count
* @param length - Amount of returned candidates (default: 5)
* @returns Returns the 5 top candidates with highest counts
*/
export function getTopCandidates(candidates: Tempo[], length = 5): Tempo[] {
return candidates.sort((a, b) => (b.count - a.count)).splice(0, length);
}
/**
* Gets the top candidate from the array
* @param candidates - BPMs with counts.
* @returns Returns the top candidate with the highest count.
*/
export function getTopCandidate(candidates: Tempo[]): number {
if (candidates.length === 0) {
throw new Error('Could not find enough samples for a reliable detection.');
}
const [first] = candidates.sort((a, b) => (b.count - a.count));
return first.tempo;
}
/**
* Identify intervals between bass peaks
* @param peaks - Array of qualified bass peaks
* @returns Return a collection of intervals between peaks
*/
export function identifyIntervals(peaks: Peaks): Interval[] {
const intervals: Interval[] = [];
for (let n = 0; n < peaks.length; n++) {
for (let i = 0; i < 10; i++) {
const peak = peaks[n];
const peakIndex = n + i;
const interval = peaks[peakIndex] - peak;
/**
* Try and find a matching interval and increase it's count
*/
const foundInterval = intervals.some((intervalCount: Interval) => {
if (intervalCount.interval === interval) {
intervalCount.count += 1;
return intervalCount.count;
}
return false;
});
/**
* Add the interval to the collection if it's unique
*/
if (!foundInterval) {
const item: Interval = {
interval,
count: 1,
};
intervals.push(item);
}
}
}
return intervals;
}
/**
* Figure out best possible tempo candidates
* @param options - AnalyzerGroupByTempoOptions
* @param options.audioSampleRate - Audio sample rate
* @param options.intervalCounts - List of identified intervals
* @returns Intervals grouped with similar values
*/
export function groupByTempo({
audioSampleRate,
intervalCounts,
}: AnalyzerGroupByTempoOptions): Tempo[] {
const tempoCounts: Tempo[] = [];
for (const intervalCount of intervalCounts) {
/**
* Skip if interval is equal 0
*/
if (intervalCount.interval === 0) {
continue;
}
intervalCount.interval = Math.abs(intervalCount.interval);
/**
* Convert an interval to tempo
*/
let theoreticalTempo = (60 / (intervalCount.interval / audioSampleRate));
/**
* Adjust the tempo to fit within the 90-180 BPM range
*/
while (theoreticalTempo < 90) {
theoreticalTempo *= 2;
}
while (theoreticalTempo > 180) {
theoreticalTempo /= 2;
}
/**
* Round to legible integer
*/
theoreticalTempo = Math.round(theoreticalTempo);
/**
* See if another interval resolved to the same tempo
*/
const foundTempo: boolean = tempoCounts.some((tempoCount: Tempo) => {
if (tempoCount.tempo === theoreticalTempo) {
tempoCount.count += intervalCount.count;
return tempoCount.count;
}
return false;
});
/**
* Add a unique tempo to the collection
*/
if (!foundTempo) {
const tempo: Tempo = {
tempo: theoreticalTempo,
count: intervalCount.count,
confidence: 0,
};
tempoCounts.push(tempo);
}
}
return tempoCounts;
}
/**
* Fastest way to detect the BPM from an AudioBuffer
* @param originalBuffer - AudioBuffer
* @param options - BiquadFilterOptions
* @returns Returns the best candidates
*/
export async function analyzeFullBuffer(originalBuffer: AudioBuffer, options?: BiquadFilterOptions): Promise<Tempo[]> {
const buffer = await getOfflineLowPassSource(originalBuffer, options);
const channelData = buffer.getChannelData(0);
const {peaks} = await findPeaks({audioSampleRate: buffer.sampleRate, channelData});
const intervals = identifyIntervals(peaks);
const tempos = groupByTempo({audioSampleRate: buffer.sampleRate, intervalCounts: intervals});
const topCandidates = getTopCandidates(tempos);
return topCandidates;
}