feat: implemented chord detection feature for MIDI regions
This commit is contained in:
@@ -21,6 +21,12 @@ export interface ChordDetectionOptionsResult {
|
||||
enableSevenths: boolean;
|
||||
}
|
||||
|
||||
export interface MidiChordDetectionOptionsResult {
|
||||
enableSevenths: boolean;
|
||||
shortNoteSuppression: 'low' | 'medium' | 'high';
|
||||
harmonicFocus: 'balanced' | 'favor-sustained-notes';
|
||||
}
|
||||
|
||||
export interface ChoiceOption {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -32,6 +38,7 @@ let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOpt
|
||||
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
|
||||
let _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>) | null = null;
|
||||
let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>) | null = null;
|
||||
|
||||
export function registerDialogFns(
|
||||
alertFn: (message: string) => Promise<void>,
|
||||
@@ -40,6 +47,7 @@ export function registerDialogFns(
|
||||
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
||||
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
||||
chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>,
|
||||
midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>,
|
||||
) {
|
||||
_showAlertFn = alertFn;
|
||||
_showConfirmFn = confirmFn;
|
||||
@@ -47,6 +55,7 @@ export function registerDialogFns(
|
||||
_showTimeSigFn = timeSigFn;
|
||||
if (choiceFn) _showChoiceFn = choiceFn;
|
||||
if (chordDetectionOptionsFn) _showChordDetectionOptionsFn = chordDetectionOptionsFn;
|
||||
if (midiChordDetectionOptionsFn) _showMidiChordDetectionOptionsFn = midiChordDetectionOptionsFn;
|
||||
}
|
||||
|
||||
export function showAlert(message: string): Promise<void> {
|
||||
@@ -103,3 +112,17 @@ export function showChordDetectionOptions(
|
||||
}
|
||||
return _showChordDetectionOptionsFn(message, defaultValue);
|
||||
}
|
||||
|
||||
export function showMidiChordDetectionOptions(
|
||||
message: string,
|
||||
defaultValue?: MidiChordDetectionOptionsResult,
|
||||
): Promise<MidiChordDetectionOptionsResult | null> {
|
||||
if (!_showMidiChordDetectionOptionsFn) {
|
||||
return Promise.resolve(defaultValue ?? {
|
||||
enableSevenths: false,
|
||||
shortNoteSuppression: 'medium',
|
||||
harmonicFocus: 'favor-sustained-notes',
|
||||
});
|
||||
}
|
||||
return _showMidiChordDetectionOptionsFn(message, defaultValue);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGProject } from '../core/KGProject';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import {
|
||||
DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||
buildMidiChordWindowsForRegion,
|
||||
detectChordsFromMidi,
|
||||
type MidiChordDetectionOptions,
|
||||
} from './midiChordDetection';
|
||||
|
||||
function createProject(): KGProject {
|
||||
return new KGProject('Chord Test', 32, 0, 125, { numerator: 4, denominator: 4 });
|
||||
}
|
||||
|
||||
function createRegion(notes: Array<{ startBeat: number; endBeat: number; pitch: number; velocity?: number }>, startFromBeat = 0, length = 4): KGMidiRegion {
|
||||
const region = new KGMidiRegion('region-1', '1', 0, 'Region', startFromBeat, length);
|
||||
notes.forEach((note, index) => {
|
||||
region.addNote(new KGMidiNote(
|
||||
`note-${index}`,
|
||||
note.startBeat,
|
||||
note.endBeat,
|
||||
note.pitch,
|
||||
note.velocity ?? 100,
|
||||
));
|
||||
});
|
||||
return region;
|
||||
}
|
||||
|
||||
function detectRegionChords(
|
||||
region: KGMidiRegion,
|
||||
options?: Partial<MidiChordDetectionOptions>,
|
||||
) {
|
||||
const project = createProject();
|
||||
const windows = buildMidiChordWindowsForRegion(project, region);
|
||||
return detectChordsFromMidi({
|
||||
project,
|
||||
region,
|
||||
windows,
|
||||
options: {
|
||||
...DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('midi chord detection', () => {
|
||||
it('detects a clean minor triad', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 4, pitch: 45 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 57 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 60 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 64 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region)[0]?.symbol).toBe('Am');
|
||||
});
|
||||
|
||||
it('detects a dominant seventh when enabled', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 4, pitch: 52 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 64 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 68 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 71 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 74 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region, { enableSevenths: true })[0]?.symbol).toBe('E7');
|
||||
});
|
||||
|
||||
it('keeps short melody notes from flipping the chord', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 4, pitch: 45 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 57 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 60 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 64 },
|
||||
{ startBeat: 0.25, endBeat: 0.5, pitch: 67 },
|
||||
{ startBeat: 1.25, endBeat: 1.5, pitch: 71 },
|
||||
{ startBeat: 2.25, endBeat: 2.5, pitch: 74 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region)[0]?.symbol).toBe('Am');
|
||||
});
|
||||
|
||||
it('prefers the sustained harmony over non-chord embellishments', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 4, pitch: 41 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 53 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 57 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 60 },
|
||||
{ startBeat: 0, endBeat: 0.25, pitch: 62 },
|
||||
{ startBeat: 1, endBeat: 1.25, pitch: 64 },
|
||||
{ startBeat: 2, endBeat: 2.25, pitch: 67 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region)[0]?.symbol).toBe('F');
|
||||
});
|
||||
|
||||
it('returns no chord for sparse windows', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 0.5, pitch: 60 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region)[0]?.symbol).toBe('N');
|
||||
});
|
||||
|
||||
it('resolves inversions to the intended root chord', () => {
|
||||
const region = createRegion([
|
||||
{ startBeat: 0, endBeat: 4, pitch: 64 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 69 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 72 },
|
||||
{ startBeat: 0, endBeat: 4, pitch: 76 },
|
||||
]);
|
||||
|
||||
expect(detectRegionChords(region)[0]?.symbol).toBe('Am');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { KGProject } from '../core/KGProject';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
|
||||
const ROOT_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;
|
||||
|
||||
export interface MidiChordWindow {
|
||||
barIndex: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
}
|
||||
|
||||
export interface MidiChordDetectionOptions {
|
||||
enableSevenths: boolean;
|
||||
shortNoteSuppression: 'low' | 'medium' | 'high';
|
||||
harmonicFocus: 'balanced' | 'favor-sustained-notes';
|
||||
}
|
||||
|
||||
export interface MidiChordDetectionRequest {
|
||||
project: KGProject;
|
||||
region: KGMidiRegion;
|
||||
windows: MidiChordWindow[];
|
||||
options: MidiChordDetectionOptions;
|
||||
}
|
||||
|
||||
export interface DetectedMidiChord {
|
||||
barIndex: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
symbol: string;
|
||||
confidence: number;
|
||||
noteCount: number;
|
||||
}
|
||||
|
||||
interface WeightedMidiNote {
|
||||
note: KGMidiNote;
|
||||
overlapBeats: number;
|
||||
baseWeight: number;
|
||||
pitchClass: number;
|
||||
absoluteStartBeat: number;
|
||||
absoluteEndBeat: number;
|
||||
}
|
||||
|
||||
interface ChordTemplate {
|
||||
symbolSuffix: '' | 'm' | '7' | 'maj7' | 'm7';
|
||||
quality: 'major' | 'minor';
|
||||
intervals: number[];
|
||||
}
|
||||
|
||||
interface ScoredChordCandidate {
|
||||
symbol: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORT_NOTE_THRESHOLDS: Record<MidiChordDetectionOptions['shortNoteSuppression'], number> = {
|
||||
low: 0.25,
|
||||
medium: 0.5,
|
||||
high: 0.75,
|
||||
};
|
||||
|
||||
const CHORD_TEMPLATES: ChordTemplate[] = [
|
||||
{ symbolSuffix: '', quality: 'major', intervals: [0, 4, 7] },
|
||||
{ symbolSuffix: 'm', quality: 'minor', intervals: [0, 3, 7] },
|
||||
{ symbolSuffix: '7', quality: 'major', intervals: [0, 4, 7, 10] },
|
||||
{ symbolSuffix: 'maj7', quality: 'major', intervals: [0, 4, 7, 11] },
|
||||
{ symbolSuffix: 'm7', quality: 'minor', intervals: [0, 3, 7, 10] },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function createPitchClassWeights(): Float64Array {
|
||||
return new Float64Array(12);
|
||||
}
|
||||
|
||||
function getNoteOverlapInfo(
|
||||
region: KGMidiRegion,
|
||||
note: KGMidiNote,
|
||||
window: MidiChordWindow,
|
||||
options: MidiChordDetectionOptions,
|
||||
): WeightedMidiNote | null {
|
||||
const absoluteStartBeat = region.getStartFromBeat() + note.getStartBeat();
|
||||
const absoluteEndBeat = region.getStartFromBeat() + note.getEndBeat();
|
||||
const overlapBeats = Math.min(absoluteEndBeat, window.endBeat) - Math.max(absoluteStartBeat, window.startBeat);
|
||||
|
||||
if (overlapBeats <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suppressionThreshold = DEFAULT_SHORT_NOTE_THRESHOLDS[options.shortNoteSuppression];
|
||||
const shortNoteFactor = overlapBeats >= suppressionThreshold
|
||||
? 1
|
||||
: clamp(overlapBeats / suppressionThreshold, 0.18, 1);
|
||||
const velocityFactor = 0.92 + ((clamp(note.getVelocity(), 1, 127) - 1) / 126) * 0.08;
|
||||
const baseWeight = overlapBeats * shortNoteFactor * velocityFactor;
|
||||
|
||||
return {
|
||||
note,
|
||||
overlapBeats,
|
||||
baseWeight,
|
||||
pitchClass: ((note.getPitch() % 12) + 12) % 12,
|
||||
absoluteStartBeat,
|
||||
absoluteEndBeat,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSustainedNotes(
|
||||
notes: WeightedMidiNote[],
|
||||
options: MidiChordDetectionOptions,
|
||||
): WeightedMidiNote[] {
|
||||
if (notes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const longestOverlap = notes.reduce((max, note) => Math.max(max, note.overlapBeats), 0);
|
||||
const overlapRatioFloor = options.harmonicFocus === 'favor-sustained-notes' ? 0.72 : 0.55;
|
||||
const weightRatioFloor = options.harmonicFocus === 'favor-sustained-notes' ? 0.68 : 0.5;
|
||||
const maxWeight = notes.reduce((max, note) => Math.max(max, note.baseWeight), 0);
|
||||
|
||||
const sustained = notes.filter(note => (
|
||||
note.overlapBeats >= longestOverlap * overlapRatioFloor ||
|
||||
note.baseWeight >= maxWeight * weightRatioFloor
|
||||
));
|
||||
|
||||
return sustained.length > 0 ? sustained : [...notes];
|
||||
}
|
||||
|
||||
function accumulatePitchClassWeights(notes: WeightedMidiNote[]): Float64Array {
|
||||
const weights = createPitchClassWeights();
|
||||
for (const note of notes) {
|
||||
weights[note.pitchClass] += note.baseWeight;
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
function normalizePitchClassWeights(weights: Float64Array): Float64Array {
|
||||
const total = weights.reduce((sum, value) => sum + value, 0);
|
||||
if (total <= 0) {
|
||||
return weights;
|
||||
}
|
||||
|
||||
const normalized = createPitchClassWeights();
|
||||
for (let index = 0; index < weights.length; index++) {
|
||||
normalized[index] = weights[index] / total;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getBassPitchClass(notes: WeightedMidiNote[]): number | null {
|
||||
if (notes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bassNote = notes[0];
|
||||
for (const note of notes) {
|
||||
if (note.note.getPitch() < bassNote.note.getPitch()) {
|
||||
bassNote = note;
|
||||
}
|
||||
}
|
||||
return bassNote.pitchClass;
|
||||
}
|
||||
|
||||
function scoreChordTemplate(
|
||||
root: number,
|
||||
template: ChordTemplate,
|
||||
sustainedWeights: Float64Array,
|
||||
fullWeights: Float64Array,
|
||||
bassPitchClass: number | null,
|
||||
options: MidiChordDetectionOptions,
|
||||
): number {
|
||||
const chordPitchClasses = template.intervals.map(interval => (root + interval) % 12);
|
||||
const chordPitchClassSet = new Set(chordPitchClasses);
|
||||
const focusWeight = options.harmonicFocus === 'favor-sustained-notes' ? 0.78 : 0.6;
|
||||
const contextWeight = 1 - focusWeight;
|
||||
const rootPc = root;
|
||||
const thirdPc = chordPitchClasses[1];
|
||||
const fifthPc = chordPitchClasses[2];
|
||||
const seventhPc = chordPitchClasses[3] ?? null;
|
||||
|
||||
let score = 0;
|
||||
score += (sustainedWeights[rootPc] * 1.7 + fullWeights[rootPc] * 1.1) * focusWeight;
|
||||
score += (sustainedWeights[thirdPc] * 1.55 + fullWeights[thirdPc] * 1.0) * focusWeight;
|
||||
score += (sustainedWeights[fifthPc] * 1.2 + fullWeights[fifthPc] * 0.8) * focusWeight;
|
||||
|
||||
if (seventhPc !== null) {
|
||||
score += (sustainedWeights[seventhPc] * 0.95 + fullWeights[seventhPc] * 1.0) * contextWeight;
|
||||
}
|
||||
|
||||
let outsidePenalty = 0;
|
||||
for (let pitchClass = 0; pitchClass < 12; pitchClass++) {
|
||||
if (chordPitchClassSet.has(pitchClass)) {
|
||||
continue;
|
||||
}
|
||||
outsidePenalty += (sustainedWeights[pitchClass] * 1.35) + (fullWeights[pitchClass] * 0.72);
|
||||
}
|
||||
score -= outsidePenalty;
|
||||
|
||||
if (sustainedWeights[thirdPc] < 0.08 && fullWeights[thirdPc] < 0.09) {
|
||||
score -= 0.28;
|
||||
}
|
||||
|
||||
if (seventhPc !== null && fullWeights[seventhPc] < 0.085) {
|
||||
score -= 0.18;
|
||||
}
|
||||
|
||||
if (bassPitchClass !== null) {
|
||||
if (bassPitchClass === rootPc) {
|
||||
score += 0.22;
|
||||
} else if (chordPitchClassSet.has(bassPitchClass)) {
|
||||
score += 0.05;
|
||||
} else {
|
||||
score -= 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function buildChordCandidates(
|
||||
sustainedWeights: Float64Array,
|
||||
fullWeights: Float64Array,
|
||||
bassPitchClass: number | null,
|
||||
options: MidiChordDetectionOptions,
|
||||
): { best: ScoredChordCandidate; second: ScoredChordCandidate } {
|
||||
const allowedTemplates = options.enableSevenths
|
||||
? CHORD_TEMPLATES
|
||||
: CHORD_TEMPLATES.filter(template => template.symbolSuffix === '' || template.symbolSuffix === 'm');
|
||||
let best: ScoredChordCandidate = { symbol: 'N', score: Number.NEGATIVE_INFINITY };
|
||||
let second: ScoredChordCandidate = { symbol: 'N', score: Number.NEGATIVE_INFINITY };
|
||||
|
||||
for (let root = 0; root < 12; root++) {
|
||||
for (const template of allowedTemplates) {
|
||||
const symbol = `${ROOT_NAMES[root]}${template.symbolSuffix}`;
|
||||
const score = scoreChordTemplate(root, template, sustainedWeights, fullWeights, bassPitchClass, options);
|
||||
const candidate = { symbol, score };
|
||||
if (candidate.score > best.score) {
|
||||
second = best;
|
||||
best = candidate;
|
||||
} else if (candidate.score > second.score) {
|
||||
second = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { best, second };
|
||||
}
|
||||
|
||||
function analyzeMidiChordWindow(
|
||||
region: KGMidiRegion,
|
||||
window: MidiChordWindow,
|
||||
options: MidiChordDetectionOptions,
|
||||
): { symbol: string; confidence: number; noteCount: number } {
|
||||
const overlappingNotes = region.getNotes()
|
||||
.map(note => getNoteOverlapInfo(region, note, window, options))
|
||||
.filter((note): note is WeightedMidiNote => note !== null);
|
||||
|
||||
if (overlappingNotes.length < 2) {
|
||||
return { symbol: 'N', confidence: 0, noteCount: overlappingNotes.length };
|
||||
}
|
||||
|
||||
const sustainedNotes = selectSustainedNotes(overlappingNotes, options);
|
||||
const sustainedDistinctPitchClasses = new Set(sustainedNotes.map(note => note.pitchClass));
|
||||
if (sustainedDistinctPitchClasses.size < 2) {
|
||||
return { symbol: 'N', confidence: 0, noteCount: overlappingNotes.length };
|
||||
}
|
||||
|
||||
const sustainedWeights = normalizePitchClassWeights(accumulatePitchClassWeights(sustainedNotes));
|
||||
const fullWeights = normalizePitchClassWeights(accumulatePitchClassWeights(overlappingNotes));
|
||||
const bassPitchClass = getBassPitchClass(sustainedNotes);
|
||||
const { best, second } = buildChordCandidates(sustainedWeights, fullWeights, bassPitchClass, options);
|
||||
|
||||
const rootName = best.symbol.endsWith('maj7')
|
||||
? best.symbol.slice(0, -4)
|
||||
: best.symbol.endsWith('m7')
|
||||
? best.symbol.slice(0, -2)
|
||||
: best.symbol.endsWith('7')
|
||||
? best.symbol.slice(0, -1)
|
||||
: best.symbol.endsWith('m')
|
||||
? best.symbol.slice(0, -1)
|
||||
: best.symbol;
|
||||
const rootPitchClass = ROOT_NAMES.indexOf(rootName as typeof ROOT_NAMES[number]);
|
||||
const rootWeight = rootPitchClass >= 0 ? sustainedWeights[rootPitchClass] + fullWeights[rootPitchClass] : 0;
|
||||
const confidence = clamp((best.score - second.score) + (rootWeight * 0.65), 0, 1);
|
||||
|
||||
if (best.score < 0.16 || confidence < 0.12) {
|
||||
return { symbol: 'N', confidence: 0, noteCount: overlappingNotes.length };
|
||||
}
|
||||
|
||||
return {
|
||||
symbol: best.symbol,
|
||||
confidence,
|
||||
noteCount: overlappingNotes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_MIDI_CHORD_DETECTION_OPTIONS: MidiChordDetectionOptions = {
|
||||
enableSevenths: false,
|
||||
shortNoteSuppression: 'medium',
|
||||
harmonicFocus: 'favor-sustained-notes',
|
||||
};
|
||||
|
||||
export function buildMidiChordWindowsForRegion(
|
||||
project: KGProject,
|
||||
midiRegion: KGMidiRegion,
|
||||
): MidiChordWindow[] {
|
||||
const regionStartBeat = midiRegion.getStartFromBeat();
|
||||
const regionEndBeat = regionStartBeat + midiRegion.getLength();
|
||||
if (regionEndBeat <= regionStartBeat) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const startBarIndex = Math.floor(regionStartBeat / beatsPerBar);
|
||||
const lastBeatExclusive = regionEndBeat - 1e-9;
|
||||
const endBarIndexExclusive = Math.max(
|
||||
startBarIndex + 1,
|
||||
Math.ceil(Math.max(regionStartBeat, lastBeatExclusive) / beatsPerBar),
|
||||
);
|
||||
|
||||
const windows: MidiChordWindow[] = [];
|
||||
for (let barIndex = startBarIndex; barIndex < endBarIndexExclusive; barIndex++) {
|
||||
const barStartBeat = barIndex * beatsPerBar;
|
||||
const barEndBeat = barStartBeat + beatsPerBar;
|
||||
const overlapStartBeat = Math.max(regionStartBeat, barStartBeat);
|
||||
const overlapEndBeat = Math.min(regionEndBeat, barEndBeat);
|
||||
if (overlapEndBeat <= overlapStartBeat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
windows.push({
|
||||
barIndex,
|
||||
startBeat: overlapStartBeat,
|
||||
endBeat: overlapEndBeat,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
export function detectChordsFromMidi(request: MidiChordDetectionRequest): DetectedMidiChord[] {
|
||||
const options: MidiChordDetectionOptions = {
|
||||
...DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||
...request.options,
|
||||
};
|
||||
|
||||
return request.windows.map(window => {
|
||||
const analysis = analyzeMidiChordWindow(request.region, window, options);
|
||||
return {
|
||||
barIndex: window.barIndex,
|
||||
startBeat: window.startBeat,
|
||||
endBeat: window.endBeat,
|
||||
symbol: analysis.symbol,
|
||||
confidence: analysis.confidence,
|
||||
noteCount: analysis.noteCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user