feat: implemented chord detection feature for MIDI regions
This commit is contained in:
Executable
+486
@@ -0,0 +1,486 @@
|
|||||||
|
{
|
||||||
|
"name": "chord-midi conversion test",
|
||||||
|
"maxBars": 32,
|
||||||
|
"currentBars": 0,
|
||||||
|
"timeSignature": {
|
||||||
|
"numerator": 4,
|
||||||
|
"denominator": 4
|
||||||
|
},
|
||||||
|
"bpm": 125,
|
||||||
|
"keySignature": "A minor",
|
||||||
|
"selectedMode": "ionian",
|
||||||
|
"isLooping": false,
|
||||||
|
"loopingRange": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"barWidthMultiplier": 2,
|
||||||
|
"pianoRollZoom": 1,
|
||||||
|
"projectStructureVersion": 15,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"__type": "KGMidiTrack",
|
||||||
|
"name": "Melody",
|
||||||
|
"id": 1,
|
||||||
|
"trackIndex": 0,
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume": 0,
|
||||||
|
"muted": false,
|
||||||
|
"solo": false,
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"__type": "KGMidiRegion",
|
||||||
|
"id": "KGMidiRegion_1779769428819_b7o8981rl",
|
||||||
|
"trackId": "1",
|
||||||
|
"trackIndex": 0,
|
||||||
|
"name": "Chord Progression",
|
||||||
|
"startFromBeat": 0,
|
||||||
|
"length": 32,
|
||||||
|
"selected": false,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_0rub4fxuu",
|
||||||
|
"startBeat": 0,
|
||||||
|
"endBeat": 4,
|
||||||
|
"pitch": 45,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_xm390pxjt",
|
||||||
|
"startBeat": 0,
|
||||||
|
"endBeat": 4,
|
||||||
|
"pitch": 57,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_lnjgm5h4y",
|
||||||
|
"startBeat": 0,
|
||||||
|
"endBeat": 4,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_mhadr3rqs",
|
||||||
|
"startBeat": 0,
|
||||||
|
"endBeat": 4,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_6iotgyszv",
|
||||||
|
"startBeat": 4,
|
||||||
|
"endBeat": 8,
|
||||||
|
"pitch": 41,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_t948mgv63",
|
||||||
|
"startBeat": 4,
|
||||||
|
"endBeat": 8,
|
||||||
|
"pitch": 53,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_17lfthyzq",
|
||||||
|
"startBeat": 4,
|
||||||
|
"endBeat": 8,
|
||||||
|
"pitch": 57,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_8rck5x4qu",
|
||||||
|
"startBeat": 4,
|
||||||
|
"endBeat": 8,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_7lzkhxewk",
|
||||||
|
"startBeat": 8,
|
||||||
|
"endBeat": 12,
|
||||||
|
"pitch": 50,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_vudwound0",
|
||||||
|
"startBeat": 8,
|
||||||
|
"endBeat": 12,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_3dlhf70kk",
|
||||||
|
"startBeat": 8,
|
||||||
|
"endBeat": 12,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_yqcv8cfm1",
|
||||||
|
"startBeat": 8,
|
||||||
|
"endBeat": 12,
|
||||||
|
"pitch": 69,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_uyz56w2ln",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 52,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_datxn33uz",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_sd8mv9pr6",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 68,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_ja7sz9w2k",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 71,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_f68y3owck",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 74,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_2kfouqrvw",
|
||||||
|
"startBeat": 16,
|
||||||
|
"endBeat": 20,
|
||||||
|
"pitch": 45,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_fvlkoqryj",
|
||||||
|
"startBeat": 16,
|
||||||
|
"endBeat": 20,
|
||||||
|
"pitch": 57,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_w9bz827to",
|
||||||
|
"startBeat": 16,
|
||||||
|
"endBeat": 20,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_0tqsc6c0m",
|
||||||
|
"startBeat": 16,
|
||||||
|
"endBeat": 20,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_ixzfcrjvc",
|
||||||
|
"startBeat": 20,
|
||||||
|
"endBeat": 24,
|
||||||
|
"pitch": 48,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_fyhjqc8iz",
|
||||||
|
"startBeat": 20,
|
||||||
|
"endBeat": 24,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_h6o16p7os",
|
||||||
|
"startBeat": 20,
|
||||||
|
"endBeat": 24,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_pruviieci",
|
||||||
|
"startBeat": 20,
|
||||||
|
"endBeat": 24,
|
||||||
|
"pitch": 67,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_tnskhun3e",
|
||||||
|
"startBeat": 24,
|
||||||
|
"endBeat": 28,
|
||||||
|
"pitch": 50,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_wm50u0odu",
|
||||||
|
"startBeat": 24,
|
||||||
|
"endBeat": 28,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_6xj1yzqhb",
|
||||||
|
"startBeat": 24,
|
||||||
|
"endBeat": 28,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_nm8rlk6bl",
|
||||||
|
"startBeat": 24,
|
||||||
|
"endBeat": 28,
|
||||||
|
"pitch": 69,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_dwu4rjqeb",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 52,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_rq9tzzjee",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_r678w4eew",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 68,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_d2akv8vpt",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 71,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1779769428819_q2l671ma4",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 74,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pitchBends": [],
|
||||||
|
"controllerEventsByType": [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumeAutomation": [],
|
||||||
|
"panAutomation": [],
|
||||||
|
"instrument": "acoustic_grand_piano"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"globalTracks": [
|
||||||
|
{
|
||||||
|
"__type": "KGMarkerTrack",
|
||||||
|
"id": "global-marker",
|
||||||
|
"trackIndex": 0,
|
||||||
|
"type": "marker",
|
||||||
|
"name": "Marker",
|
||||||
|
"regions": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__type": "KGTempoTrack",
|
||||||
|
"id": "global-tempo",
|
||||||
|
"trackIndex": 1,
|
||||||
|
"type": "tempo",
|
||||||
|
"name": "Tempo",
|
||||||
|
"regions": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__type": "KGSignatureTrack",
|
||||||
|
"id": "global-signature",
|
||||||
|
"trackIndex": 2,
|
||||||
|
"type": "signature",
|
||||||
|
"name": "Key Signature",
|
||||||
|
"regions": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__type": "KGChordTrack",
|
||||||
|
"id": "global-chord",
|
||||||
|
"trackIndex": 3,
|
||||||
|
"type": "chord",
|
||||||
|
"name": "Chord",
|
||||||
|
"regions": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,32 +6,43 @@ import type {
|
|||||||
ChoiceOption,
|
ChoiceOption,
|
||||||
ChordDetectionOptionsResult,
|
ChordDetectionOptionsResult,
|
||||||
ConfirmOptions,
|
ConfirmOptions,
|
||||||
|
MidiChordDetectionOptionsResult,
|
||||||
PromptOptions,
|
PromptOptions,
|
||||||
TimeSigResult,
|
TimeSigResult,
|
||||||
} from '../../util/dialogUtil';
|
} from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface DialogInfo {
|
interface DialogInfo {
|
||||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection';
|
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection';
|
||||||
message: string;
|
message: string;
|
||||||
options?: ConfirmOptions | PromptOptions;
|
options?: ConfirmOptions | PromptOptions;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
defaultTimeSig?: TimeSigResult;
|
defaultTimeSig?: TimeSigResult;
|
||||||
choices?: ChoiceOption[];
|
choices?: ChoiceOption[];
|
||||||
defaultChordDetectionOptions?: ChordDetectionOptionsResult;
|
defaultChordDetectionOptions?: ChordDetectionOptionsResult;
|
||||||
|
defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = {
|
||||||
|
sensitivity: 50,
|
||||||
|
stability: 50,
|
||||||
|
noChordThreshold: 0,
|
||||||
|
enableSevenths: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MIDI_CHORD_DETECTION_OPTIONS: MidiChordDetectionOptionsResult = {
|
||||||
|
enableSevenths: false,
|
||||||
|
shortNoteSuppression: 'medium',
|
||||||
|
harmonicFocus: 'favor-sustained-notes',
|
||||||
|
};
|
||||||
|
|
||||||
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [dialog, setDialog] = useState<DialogInfo | null>(null);
|
const [dialog, setDialog] = useState<DialogInfo | null>(null);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [timeSigNumerator, setTimeSigNumerator] = useState('');
|
const [timeSigNumerator, setTimeSigNumerator] = useState('');
|
||||||
const [timeSigDenominator, setTimeSigDenominator] = useState('');
|
const [timeSigDenominator, setTimeSigDenominator] = useState('');
|
||||||
const [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>({
|
const [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
|
||||||
sensitivity: 50,
|
const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState<MidiChordDetectionOptionsResult>(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
|
||||||
stability: 50,
|
|
||||||
noChordThreshold: 0,
|
|
||||||
enableSevenths: false,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const resolveRef = useRef<((value: any) => void) | null>(null);
|
const resolveRef = useRef<((value: any) => void) | null>(null);
|
||||||
const pendingValueRef = useRef<unknown>(undefined);
|
const pendingValueRef = useRef<unknown>(undefined);
|
||||||
@@ -80,16 +91,22 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
): Promise<ChordDetectionOptionsResult | null> => {
|
): Promise<ChordDetectionOptionsResult | null> => {
|
||||||
return new Promise<ChordDetectionOptionsResult | null>((resolve) => {
|
return new Promise<ChordDetectionOptionsResult | null>((resolve) => {
|
||||||
resolveRef.current = resolve;
|
resolveRef.current = resolve;
|
||||||
setChordDetectionOptions(defaultValue ?? {
|
setChordDetectionOptions(defaultValue ?? DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
|
||||||
sensitivity: 50,
|
|
||||||
stability: 50,
|
|
||||||
noChordThreshold: 0,
|
|
||||||
enableSevenths: false,
|
|
||||||
});
|
|
||||||
setDialog({ type: 'chord-detection', message, defaultChordDetectionOptions: defaultValue });
|
setDialog({ type: 'chord-detection', message, defaultChordDetectionOptions: defaultValue });
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openMidiChordDetectionOptions = useCallback((
|
||||||
|
message: string,
|
||||||
|
defaultValue?: MidiChordDetectionOptionsResult,
|
||||||
|
): Promise<MidiChordDetectionOptionsResult | null> => {
|
||||||
|
return new Promise<MidiChordDetectionOptionsResult | null>((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setMidiChordDetectionOptions(defaultValue ?? DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
|
||||||
|
setDialog({ type: 'midi-chord-detection', message, defaultMidiChordDetectionOptions: defaultValue });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const close = useCallback((value: unknown) => {
|
const close = useCallback((value: unknown) => {
|
||||||
pendingValueRef.current = value;
|
pendingValueRef.current = value;
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
@@ -103,12 +120,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
setInputValue('');
|
setInputValue('');
|
||||||
setTimeSigNumerator('');
|
setTimeSigNumerator('');
|
||||||
setTimeSigDenominator('');
|
setTimeSigDenominator('');
|
||||||
setChordDetectionOptions({
|
setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
|
||||||
sensitivity: 50,
|
setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
|
||||||
stability: 50,
|
|
||||||
noChordThreshold: 0,
|
|
||||||
enableSevenths: false,
|
|
||||||
});
|
|
||||||
if (resolveRef.current) {
|
if (resolveRef.current) {
|
||||||
resolveRef.current(pendingValueRef.current);
|
resolveRef.current(pendingValueRef.current);
|
||||||
resolveRef.current = null;
|
resolveRef.current = null;
|
||||||
@@ -120,7 +133,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
const registered = useRef(false);
|
const registered = useRef(false);
|
||||||
if (!registered.current) {
|
if (!registered.current) {
|
||||||
registered.current = true;
|
registered.current = true;
|
||||||
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice, openChordDetectionOptions);
|
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice, openChordDetectionOptions, openMidiChordDetectionOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
@@ -132,13 +145,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
const isTimeSig = dialog.type === 'timesig';
|
const isTimeSig = dialog.type === 'timesig';
|
||||||
const isChoice = dialog.type === 'choice';
|
const isChoice = dialog.type === 'choice';
|
||||||
const isChordDetection = dialog.type === 'chord-detection';
|
const isChordDetection = dialog.type === 'chord-detection';
|
||||||
|
const isMidiChordDetection = dialog.type === 'midi-chord-detection';
|
||||||
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
||||||
|
|
||||||
const title = isAlert
|
const title = isAlert
|
||||||
? 'Notice'
|
? 'Notice'
|
||||||
: isTimeSig
|
: isTimeSig
|
||||||
? 'Time Signature'
|
? 'Time Signature'
|
||||||
: isChordDetection
|
: (isChordDetection || isMidiChordDetection)
|
||||||
? 'Chord Detection'
|
? 'Chord Detection'
|
||||||
: isPrompt
|
: isPrompt
|
||||||
? 'Input'
|
? 'Input'
|
||||||
@@ -167,6 +181,10 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
close(chordDetectionOptions);
|
close(chordDetectionOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isMidiChordDetection) {
|
||||||
|
close(midiChordDetectionOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close(true);
|
close(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,6 +195,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
setChordDetectionOptions(current => ({ ...current, [key]: value }));
|
setChordDetectionOptions(current => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateMidiChordDetectionOption = <K extends keyof MidiChordDetectionOptionsResult>(
|
||||||
|
key: K,
|
||||||
|
value: MidiChordDetectionOptionsResult[K],
|
||||||
|
) => {
|
||||||
|
setMidiChordDetectionOptions(current => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -298,6 +323,49 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isMidiChordDetection && (
|
||||||
|
<div className="dialog-chord-detection-form">
|
||||||
|
<div className="dialog-slider-group">
|
||||||
|
<div className="dialog-slider-header">
|
||||||
|
<label className="dialog-slider-label" htmlFor="dialog-midi-short-note-suppression">Short Notes</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="dialog-midi-short-note-suppression"
|
||||||
|
className="dialog-input"
|
||||||
|
value={midiChordDetectionOptions.shortNoteSuppression}
|
||||||
|
onChange={(e) => updateMidiChordDetectionOption('shortNoteSuppression', e.target.value as MidiChordDetectionOptionsResult['shortNoteSuppression'])}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<option value="low">Low suppression</option>
|
||||||
|
<option value="medium">Medium suppression</option>
|
||||||
|
<option value="high">High suppression</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-slider-group">
|
||||||
|
<div className="dialog-slider-header">
|
||||||
|
<label className="dialog-slider-label" htmlFor="dialog-midi-harmonic-focus">Harmonic Focus</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="dialog-midi-harmonic-focus"
|
||||||
|
className="dialog-input"
|
||||||
|
value={midiChordDetectionOptions.harmonicFocus}
|
||||||
|
onChange={(e) => updateMidiChordDetectionOption('harmonicFocus', e.target.value as MidiChordDetectionOptionsResult['harmonicFocus'])}
|
||||||
|
>
|
||||||
|
<option value="balanced">Balanced</option>
|
||||||
|
<option value="favor-sustained-notes">Favor sustained notes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="dialog-checkbox-row" htmlFor="dialog-midi-enable-sevenths">
|
||||||
|
<input
|
||||||
|
id="dialog-midi-enable-sevenths"
|
||||||
|
type="checkbox"
|
||||||
|
checked={midiChordDetectionOptions.enableSevenths}
|
||||||
|
onChange={(e) => updateMidiChordDetectionOption('enableSevenths', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Chord Detail: Enable sevenths</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-footer">
|
<div className="dialog-footer">
|
||||||
{!isAlert && (
|
{!isAlert && (
|
||||||
@@ -323,9 +391,9 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
<button
|
<button
|
||||||
className="dialog-btn dialog-btn-primary"
|
className="dialog-btn dialog-btn-primary"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
autoFocus={!isPrompt && !isTimeSig && !isChordDetection}
|
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection}
|
||||||
>
|
>
|
||||||
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : isChordDetection ? 'Detect' : 'Yes'))}
|
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : (isChordDetection || isMidiChordDetection) ? 'Detect' : 'Yes'))}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../co
|
|||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||||
import { showAlert, showChordDetectionOptions } from '../../util/dialogUtil';
|
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions } from '../../util/dialogUtil';
|
||||||
import {
|
import {
|
||||||
normalizeSpectrogramHeightResolution,
|
normalizeSpectrogramHeightResolution,
|
||||||
type SpectrogramHeightResolution,
|
type SpectrogramHeightResolution,
|
||||||
@@ -32,6 +32,13 @@ import {
|
|||||||
type AudioChordDetectionOptions,
|
type AudioChordDetectionOptions,
|
||||||
type DetectedAudioChord,
|
type DetectedAudioChord,
|
||||||
} from '../../util/audioChordDetection';
|
} from '../../util/audioChordDetection';
|
||||||
|
import {
|
||||||
|
DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||||
|
buildMidiChordWindowsForRegion,
|
||||||
|
detectChordsFromMidi,
|
||||||
|
type DetectedMidiChord,
|
||||||
|
type MidiChordDetectionOptions,
|
||||||
|
} from '../../util/midiChordDetection';
|
||||||
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
|
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
|
||||||
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
||||||
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||||
@@ -450,21 +457,31 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDetectChords = useCallback(async () => {
|
const handleDetectChords = useCallback(async () => {
|
||||||
if (!audioRegion || !projectName || !trackId) {
|
if (!audioRegion && !activeRegion) {
|
||||||
await showAlert('Open an audio region in spectrogram mode before detecting chords.');
|
await showAlert('Open a MIDI or audio region before detecting chords.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
const windows = buildAudioChordWindowsForRegion(project, audioRegion);
|
const audioWindows = audioRegion ? buildAudioChordWindowsForRegion(project, audioRegion) : null;
|
||||||
if (windows.length === 0) {
|
const midiWindows = !audioRegion && activeRegion ? buildMidiChordWindowsForRegion(project, activeRegion) : null;
|
||||||
await showAlert('The selected audio region has no audible span to analyze.');
|
const chordWindows = audioWindows ?? midiWindows ?? [];
|
||||||
|
if (chordWindows.length === 0) {
|
||||||
|
await showAlert(audioRegion
|
||||||
|
? 'The selected audio region has no audible span to analyze.'
|
||||||
|
: 'The selected MIDI region has no bars to analyze.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectionOptions = await showChordDetectionOptions(
|
const detectionOptions = audioRegion
|
||||||
'Tune chord detection settings before processing.',
|
? await showChordDetectionOptions(
|
||||||
|
'Tune audio chord detection settings before processing.',
|
||||||
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||||
|
)
|
||||||
|
: await showMidiChordDetectionOptions(
|
||||||
|
'Tune MIDI chord detection settings before processing.',
|
||||||
|
DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||||
);
|
);
|
||||||
if (!detectionOptions) {
|
if (!detectionOptions) {
|
||||||
return;
|
return;
|
||||||
@@ -475,6 +492,13 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let detectedChords: DetectedAudioChord[] | DetectedMidiChord[];
|
||||||
|
if (audioRegion) {
|
||||||
|
if (!projectName || !trackId) {
|
||||||
|
await showAlert('Open an audio region in spectrogram mode before detecting chords.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId());
|
let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId());
|
||||||
if (!audioBuffer) {
|
if (!audioBuffer) {
|
||||||
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId());
|
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId());
|
||||||
@@ -494,11 +518,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
pcm: monoPcm,
|
pcm: monoPcm,
|
||||||
sampleRate: audioBuffer.sampleRate,
|
sampleRate: audioBuffer.sampleRate,
|
||||||
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
|
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
|
||||||
windows,
|
windows: audioWindows ?? [],
|
||||||
options: detectionOptions as AudioChordDetectionOptions,
|
options: detectionOptions as AudioChordDetectionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detectedChords = await new Promise<DetectedAudioChord[]>((resolve, reject) => {
|
detectedChords = await new Promise<DetectedAudioChord[]>((resolve, reject) => {
|
||||||
worker = new Worker(
|
worker = new Worker(
|
||||||
new URL('../../workers/audioChordDetectionWorker.ts', import.meta.url),
|
new URL('../../workers/audioChordDetectionWorker.ts', import.meta.url),
|
||||||
{ type: 'module' },
|
{ type: 'module' },
|
||||||
@@ -517,6 +541,15 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
};
|
};
|
||||||
worker.postMessage(request, [request.pcm.buffer]);
|
worker.postMessage(request, [request.pcm.buffer]);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setDetectChordProgressPercent(100);
|
||||||
|
detectedChords = detectChordsFromMidi({
|
||||||
|
project,
|
||||||
|
region: activeRegion as KGMidiRegion,
|
||||||
|
windows: midiWindows ?? [],
|
||||||
|
options: detectionOptions as MidiChordDetectionOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const replacements = detectedChords
|
const replacements = detectedChords
|
||||||
.filter(result => result.symbol !== 'N' && result.endBeat > result.startBeat)
|
.filter(result => result.symbol !== 'N' && result.endBeat > result.startBeat)
|
||||||
@@ -526,15 +559,18 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
symbol: result.symbol,
|
symbol: result.symbol,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const spanStartBeat = windows[0].startBeat;
|
const spanStartBeat = chordWindows[0].startBeat;
|
||||||
const spanEndBeat = windows[windows.length - 1].endBeat;
|
const spanEndBeat = chordWindows[chordWindows.length - 1].endBeat;
|
||||||
KGCore.instance().executeCommand(
|
KGCore.instance().executeCommand(
|
||||||
new ReplaceChordRegionsInRangeCommand(spanStartBeat, spanEndBeat, replacements),
|
new ReplaceChordRegionsInRangeCommand(spanStartBeat, spanEndBeat, replacements),
|
||||||
);
|
);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error detecting chords:', error);
|
console.error('Error detecting chords:', error);
|
||||||
await showAlert('Failed to detect chords from this audio region.');
|
await showAlert(audioRegion
|
||||||
|
? 'Failed to detect chords from this audio region.'
|
||||||
|
: 'Failed to detect chords from this MIDI region.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (worker) {
|
if (worker) {
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
@@ -542,7 +578,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
setIsDetectingChords(false);
|
setIsDetectingChords(false);
|
||||||
setDetectChordProgressPercent(0);
|
setDetectChordProgressPercent(0);
|
||||||
}
|
}
|
||||||
}, [audioRegion, projectName, refreshProjectState, trackId]);
|
}, [activeRegion, audioRegion, projectName, refreshProjectState, trackId]);
|
||||||
|
|
||||||
// Handle title click to rename the region
|
// Handle title click to rename the region
|
||||||
const handleTitleClick = () => {
|
const handleTitleClick = () => {
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ describe('PianoRollToolbar', () => {
|
|||||||
expect(onDetectChords).toHaveBeenCalledTimes(1);
|
expect(onDetectChords).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the detect chords action in midi mode and triggers it', () => {
|
||||||
|
const onDetectChords = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
mode="midi-edit"
|
||||||
|
showAutomationControls={false}
|
||||||
|
onDetectChords={onDetectChords}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('More options'));
|
||||||
|
fireEvent.click(screen.getByText('Detect chords...'));
|
||||||
|
|
||||||
|
expect(onDetectChords).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('disables the detect chords action while detection is running', () => {
|
it('disables the detect chords action while detection is running', () => {
|
||||||
const onDetectChords = vi.fn();
|
const onDetectChords = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid
|
const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid
|
||||||
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
|
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
|
||||||
|
const showDetectChordMenu = !sheetMusicViewEnabled && !!onDetectChords;
|
||||||
|
|
||||||
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
|
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
|
||||||
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
|
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -299,7 +300,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSpecControls && (
|
{showDetectChordMenu && (
|
||||||
<div className="quant-dropdown-container" ref={specMenuRef}>
|
<div className="quant-dropdown-container" ref={specMenuRef}>
|
||||||
<button
|
<button
|
||||||
className="quant-button"
|
className="quant-button"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { KGProject } from '../../core/KGProject';
|
||||||
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
|
import {
|
||||||
|
DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||||
|
buildMidiChordWindowsForRegion,
|
||||||
|
detectChordsFromMidi,
|
||||||
|
} from '../../util/midiChordDetection';
|
||||||
|
|
||||||
|
const FIXTURE_PATH = path.resolve(process.cwd(), 'public/test-data/chord-progression-01.json');
|
||||||
|
const TARGET_REGION_ID = 'KGMidiRegion_1779769428819_b7o8981rl';
|
||||||
|
|
||||||
|
function loadFixtureProject(): KGProject {
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf-8')) as Record<string, unknown>;
|
||||||
|
const deserializedResult = plainToInstance(KGProject, fixture);
|
||||||
|
const project = Array.isArray(deserializedResult) ? deserializedResult[0] ?? null : deserializedResult;
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Failed to deserialize MIDI chord detection fixture');
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixtureRegion(project: KGProject): KGMidiRegion {
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
if (!(track instanceof KGMidiTrack)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = track.getRegions().find((candidate): candidate is KGMidiRegion => (
|
||||||
|
candidate instanceof KGMidiRegion && candidate.getId() === TARGET_REGION_ID
|
||||||
|
));
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Fixture region ${TARGET_REGION_ID} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('midi chord detection fixture', () => {
|
||||||
|
it('detects the expected bar-locked progression with triads only', () => {
|
||||||
|
const project = loadFixtureProject();
|
||||||
|
const region = getFixtureRegion(project);
|
||||||
|
const windows = buildMidiChordWindowsForRegion(project, region);
|
||||||
|
const results = detectChordsFromMidi({
|
||||||
|
project,
|
||||||
|
region,
|
||||||
|
windows,
|
||||||
|
options: DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.map(result => result.symbol)).toEqual([
|
||||||
|
'Am',
|
||||||
|
'F',
|
||||||
|
'Dm',
|
||||||
|
'E',
|
||||||
|
'Am',
|
||||||
|
'C',
|
||||||
|
'Dm',
|
||||||
|
'E',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the expected bar-locked progression with sevenths enabled', () => {
|
||||||
|
const project = loadFixtureProject();
|
||||||
|
const region = getFixtureRegion(project);
|
||||||
|
const windows = buildMidiChordWindowsForRegion(project, region);
|
||||||
|
const results = detectChordsFromMidi({
|
||||||
|
project,
|
||||||
|
region,
|
||||||
|
windows,
|
||||||
|
options: {
|
||||||
|
...DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
|
||||||
|
enableSevenths: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.map(result => result.symbol)).toEqual([
|
||||||
|
'Am',
|
||||||
|
'F',
|
||||||
|
'Dm',
|
||||||
|
'E7',
|
||||||
|
'Am',
|
||||||
|
'C',
|
||||||
|
'Dm',
|
||||||
|
'E7',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,12 @@ export interface ChordDetectionOptionsResult {
|
|||||||
enableSevenths: boolean;
|
enableSevenths: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MidiChordDetectionOptionsResult {
|
||||||
|
enableSevenths: boolean;
|
||||||
|
shortNoteSuppression: 'low' | 'medium' | 'high';
|
||||||
|
harmonicFocus: 'balanced' | 'favor-sustained-notes';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChoiceOption {
|
export interface ChoiceOption {
|
||||||
label: string;
|
label: string;
|
||||||
value: 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 _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||||
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | 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 _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>) | null = null;
|
||||||
|
let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>) | null = null;
|
||||||
|
|
||||||
export function registerDialogFns(
|
export function registerDialogFns(
|
||||||
alertFn: (message: string) => Promise<void>,
|
alertFn: (message: string) => Promise<void>,
|
||||||
@@ -40,6 +47,7 @@ export function registerDialogFns(
|
|||||||
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
||||||
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
||||||
chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>,
|
chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>,
|
||||||
|
midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>,
|
||||||
) {
|
) {
|
||||||
_showAlertFn = alertFn;
|
_showAlertFn = alertFn;
|
||||||
_showConfirmFn = confirmFn;
|
_showConfirmFn = confirmFn;
|
||||||
@@ -47,6 +55,7 @@ export function registerDialogFns(
|
|||||||
_showTimeSigFn = timeSigFn;
|
_showTimeSigFn = timeSigFn;
|
||||||
if (choiceFn) _showChoiceFn = choiceFn;
|
if (choiceFn) _showChoiceFn = choiceFn;
|
||||||
if (chordDetectionOptionsFn) _showChordDetectionOptionsFn = chordDetectionOptionsFn;
|
if (chordDetectionOptionsFn) _showChordDetectionOptionsFn = chordDetectionOptionsFn;
|
||||||
|
if (midiChordDetectionOptionsFn) _showMidiChordDetectionOptionsFn = midiChordDetectionOptionsFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showAlert(message: string): Promise<void> {
|
export function showAlert(message: string): Promise<void> {
|
||||||
@@ -103,3 +112,17 @@ export function showChordDetectionOptions(
|
|||||||
}
|
}
|
||||||
return _showChordDetectionOptionsFn(message, defaultValue);
|
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