fix: keep imported MIDI in one region and expand project bars
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
beatsToBar,
|
beatsToBar,
|
||||||
|
convertMidiToProject,
|
||||||
|
convertProjectToMidi,
|
||||||
formatMidiEventLength,
|
formatMidiEventLength,
|
||||||
formatMidiEventPosition,
|
formatMidiEventPosition,
|
||||||
MIDI_EVENT_TICKS_PER_BEAT,
|
MIDI_EVENT_TICKS_PER_BEAT,
|
||||||
@@ -13,6 +15,10 @@ import {
|
|||||||
pianoRollIndexToPitch,
|
pianoRollIndexToPitch,
|
||||||
noteNameToPitch
|
noteNameToPitch
|
||||||
} from './midiUtil';
|
} from './midiUtil';
|
||||||
|
import { KGProject } from '../core/KGProject';
|
||||||
|
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||||
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
|
|
||||||
describe('midiUtil', () => {
|
describe('midiUtil', () => {
|
||||||
describe('beatsToBar', () => {
|
describe('beatsToBar', () => {
|
||||||
@@ -195,6 +201,166 @@ describe('midiUtil', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('convertMidiToProject', () => {
|
||||||
|
const createRoundTripTrack = (notes: Array<{
|
||||||
|
startBeat: number;
|
||||||
|
endBeat: number;
|
||||||
|
pitch: number;
|
||||||
|
velocity: number;
|
||||||
|
}>, options?: {
|
||||||
|
trackName?: string;
|
||||||
|
trackId?: number;
|
||||||
|
trackIndex?: number;
|
||||||
|
project?: KGProject;
|
||||||
|
}) => {
|
||||||
|
const project = options?.project ?? new KGProject('Source Project', 64, 0, 132, { numerator: 4, denominator: 4 }, 'D major');
|
||||||
|
const track = new KGMidiTrack(options?.trackName ?? 'Lead', options?.trackId ?? 1, 'acoustic_grand_piano');
|
||||||
|
track.setTrackIndex(options?.trackIndex ?? project.getTracks().length);
|
||||||
|
const region = new KGMidiRegion(
|
||||||
|
`region-${options?.trackId ?? 1}`,
|
||||||
|
String(track.getId()),
|
||||||
|
track.getTrackIndex(),
|
||||||
|
`${track.getName()} Source`,
|
||||||
|
0,
|
||||||
|
Math.max(...notes.map(note => note.endBeat), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
notes.forEach((note, index) => {
|
||||||
|
region.addNote(new KGMidiNote(
|
||||||
|
`note-${options?.trackId ?? 1}-${index}`,
|
||||||
|
note.startBeat,
|
||||||
|
note.endBeat,
|
||||||
|
note.pitch,
|
||||||
|
note.velocity
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addRegion(region);
|
||||||
|
project.getTracks().push(track);
|
||||||
|
return { project, track };
|
||||||
|
};
|
||||||
|
|
||||||
|
const importProject = (project: KGProject) => convertMidiToProject(convertProjectToMidi(project));
|
||||||
|
|
||||||
|
it('imports a short track as a single region covering the full note range', () => {
|
||||||
|
const { project } = createRoundTripTrack([
|
||||||
|
{ startBeat: 1, endBeat: 2.5, pitch: 60, velocity: 96 },
|
||||||
|
{ startBeat: 6, endBeat: 7, pitch: 64, velocity: 88 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const importedProject = importProject(project);
|
||||||
|
const importedTrack = importedProject.getTracks()[0] as KGMidiTrack;
|
||||||
|
const importedRegions = importedTrack.getRegions();
|
||||||
|
|
||||||
|
expect(importedRegions).toHaveLength(1);
|
||||||
|
expect(importedRegions[0].getStartFromBeat()).toBe(1);
|
||||||
|
expect(importedRegions[0].getLength()).toBe(6);
|
||||||
|
expect(importedRegions[0].getNotes()).toHaveLength(2);
|
||||||
|
expect(importedRegions[0].getNotes().map(note => note.getStartBeat())).toEqual([0, 5]);
|
||||||
|
expect(importedRegions[0].getNotes().map(note => note.getEndBeat())).toEqual([1.5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports a long track as a single region instead of chunking every four bars', () => {
|
||||||
|
const { project } = createRoundTripTrack([
|
||||||
|
{ startBeat: 0, endBeat: 1, pitch: 60, velocity: 100 },
|
||||||
|
{ startBeat: 20, endBeat: 21, pitch: 64, velocity: 100 },
|
||||||
|
{ startBeat: 36, endBeat: 37, pitch: 67, velocity: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const importedProject = importProject(project);
|
||||||
|
const importedTrack = importedProject.getTracks()[0] as KGMidiTrack;
|
||||||
|
const importedRegions = importedTrack.getRegions();
|
||||||
|
|
||||||
|
expect(importedRegions).toHaveLength(1);
|
||||||
|
expect(importedRegions[0].getStartFromBeat()).toBe(0);
|
||||||
|
expect(importedRegions[0].getLength()).toBe(37);
|
||||||
|
expect(importedRegions[0].getNotes().map(note => note.getStartBeat())).toEqual([0, 20, 36]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports multiple MIDI tracks as separate tracks with one region each', () => {
|
||||||
|
const project = new KGProject('Ensemble', 64, 0, 110, { numerator: 4, denominator: 4 }, 'G major');
|
||||||
|
createRoundTripTrack([
|
||||||
|
{ startBeat: 0, endBeat: 1, pitch: 60, velocity: 90 },
|
||||||
|
], { project, trackName: 'Lead', trackId: 1, trackIndex: 0 });
|
||||||
|
createRoundTripTrack([
|
||||||
|
{ startBeat: 8, endBeat: 10, pitch: 48, velocity: 80 },
|
||||||
|
], { project, trackName: 'Bass', trackId: 2, trackIndex: 1 });
|
||||||
|
|
||||||
|
const importedProject = importProject(project);
|
||||||
|
const importedTracks = importedProject.getTracks() as KGMidiTrack[];
|
||||||
|
|
||||||
|
expect(importedTracks).toHaveLength(2);
|
||||||
|
expect(importedTracks[0].getRegions()).toHaveLength(1);
|
||||||
|
expect(importedTracks[1].getRegions()).toHaveLength(1);
|
||||||
|
expect(importedTracks[0].getRegions()[0].getStartFromBeat()).toBe(0);
|
||||||
|
expect(importedTracks[1].getRegions()[0].getStartFromBeat()).toBe(8);
|
||||||
|
expect(importedTracks[1].getRegions()[0].getNotes()[0].getStartBeat()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a note that crosses the old four-bar boundary inside the single imported region', () => {
|
||||||
|
const { project } = createRoundTripTrack([
|
||||||
|
{ startBeat: 15.5, endBeat: 16.5, pitch: 72, velocity: 110 },
|
||||||
|
{ startBeat: 18, endBeat: 19, pitch: 76, velocity: 105 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const importedProject = importProject(project);
|
||||||
|
const importedTrack = importedProject.getTracks()[0] as KGMidiTrack;
|
||||||
|
const importedRegion = importedTrack.getRegions()[0];
|
||||||
|
const importedNotes = importedRegion.getNotes();
|
||||||
|
|
||||||
|
expect(importedTrack.getRegions()).toHaveLength(1);
|
||||||
|
expect(importedRegion.getStartFromBeat()).toBe(15.5);
|
||||||
|
expect(importedRegion.getLength()).toBe(3.5);
|
||||||
|
expect(importedNotes[0].getStartBeat()).toBe(0);
|
||||||
|
expect(importedNotes[0].getEndBeat()).toBe(1);
|
||||||
|
expect(importedNotes[1].getStartBeat()).toBe(2.5);
|
||||||
|
expect(importedNotes[1].getEndBeat()).toBe(3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves imported tempo, time signature, and key signature metadata', () => {
|
||||||
|
const { project } = createRoundTripTrack([
|
||||||
|
{ startBeat: 2, endBeat: 3, pitch: 60, velocity: 100 },
|
||||||
|
], {
|
||||||
|
project: new KGProject('Meta Source', 64, 0, 147, { numerator: 3, denominator: 4 }, 'A major')
|
||||||
|
});
|
||||||
|
|
||||||
|
const importedProject = importProject(project);
|
||||||
|
|
||||||
|
expect(importedProject.getBpm()).toBe(147);
|
||||||
|
expect(importedProject.getTimeSignature()).toEqual({ numerator: 3, denominator: 4 });
|
||||||
|
expect(importedProject.getKeySignature()).toBe('A major');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands max bars when imported MIDI extends beyond the current project length', () => {
|
||||||
|
const existingProject = new KGProject('Existing Project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
const sourceProject = new KGProject('Long MIDI Source', 64, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
createRoundTripTrack([
|
||||||
|
{ startBeat: 0, endBeat: 1, pitch: 60, velocity: 100 },
|
||||||
|
{ startBeat: 44, endBeat: 46, pitch: 64, velocity: 100 },
|
||||||
|
], {
|
||||||
|
project: sourceProject
|
||||||
|
});
|
||||||
|
|
||||||
|
const importedProject = convertMidiToProject(convertProjectToMidi(sourceProject), existingProject);
|
||||||
|
|
||||||
|
expect(importedProject.getMaxBars()).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not shrink max bars when imported MIDI fits within the current project length', () => {
|
||||||
|
const existingProject = new KGProject('Existing Project', 40, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
const sourceProject = new KGProject('Short MIDI Source', 64, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||||
|
createRoundTripTrack([
|
||||||
|
{ startBeat: 4, endBeat: 6, pitch: 60, velocity: 100 },
|
||||||
|
], {
|
||||||
|
project: sourceProject
|
||||||
|
});
|
||||||
|
|
||||||
|
const importedProject = convertMidiToProject(convertProjectToMidi(sourceProject), existingProject);
|
||||||
|
|
||||||
|
expect(importedProject.getMaxBars()).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle negative values gracefully', () => {
|
it('should handle negative values gracefully', () => {
|
||||||
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
||||||
|
|||||||
+37
-77
@@ -761,6 +761,7 @@ export const convertMidiToProject = (midiData: Uint8Array, existingProject?: KGP
|
|||||||
// Get existing tracks to calculate proper track indices
|
// Get existing tracks to calculate proper track indices
|
||||||
const existingTracks = project.getTracks();
|
const existingTracks = project.getTracks();
|
||||||
const startingTrackIndex = existingTracks.length;
|
const startingTrackIndex = existingTracks.length;
|
||||||
|
const importedTrackEndBeats: number[] = [];
|
||||||
|
|
||||||
// Convert MIDI tracks to KGSP tracks
|
// Convert MIDI tracks to KGSP tracks
|
||||||
let addedTrackCount = 0;
|
let addedTrackCount = 0;
|
||||||
@@ -782,46 +783,52 @@ export const convertMidiToProject = (midiData: Uint8Array, existingProject?: KGP
|
|||||||
const kgTrack = new KGMidiTrack(trackName, trackId, instrument);
|
const kgTrack = new KGMidiTrack(trackName, trackId, instrument);
|
||||||
kgTrack.setTrackIndex(actualTrackIndex);
|
kgTrack.setTrackIndex(actualTrackIndex);
|
||||||
|
|
||||||
// Group notes into regions (every 16 beats for now, could be more sophisticated)
|
const regionStartBeat = Math.min(...midiTrack.notes.map(note => note.startBeat));
|
||||||
const regions = groupNotesIntoRegions(midiTrack.notes, project.getTimeSignature());
|
const regionEndBeat = Math.max(...midiTrack.notes.map(note => note.endBeat));
|
||||||
|
importedTrackEndBeats.push(regionEndBeat);
|
||||||
|
const regionId = generateUniqueId('KGMidiRegion');
|
||||||
|
const regionName = `${trackName} Region`;
|
||||||
|
const region = new KGMidiRegion(
|
||||||
|
regionId,
|
||||||
|
trackId.toString(),
|
||||||
|
actualTrackIndex,
|
||||||
|
regionName,
|
||||||
|
regionStartBeat,
|
||||||
|
regionEndBeat - regionStartBeat
|
||||||
|
);
|
||||||
|
|
||||||
regions.forEach((regionData, regionIndex) => {
|
// Store imported notes relative to the region start while preserving their timing.
|
||||||
const regionId = generateUniqueId('KGMidiRegion');
|
midiTrack.notes.forEach(midiNote => {
|
||||||
const regionName = `${trackName} Region ${regionIndex + 1}`;
|
const noteId = generateUniqueId('KGMidiNote');
|
||||||
const region = new KGMidiRegion(
|
const relativeStartBeat = midiNote.startBeat - regionStartBeat;
|
||||||
regionId,
|
const relativeEndBeat = midiNote.endBeat - regionStartBeat;
|
||||||
trackId.toString(),
|
|
||||||
actualTrackIndex,
|
const kgNote = new KGMidiNote(
|
||||||
regionName,
|
noteId,
|
||||||
regionData.startBeat,
|
relativeStartBeat,
|
||||||
regionData.length
|
relativeEndBeat,
|
||||||
|
midiNote.pitch,
|
||||||
|
midiNote.velocity
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert MIDI notes to KG notes (with relative timing)
|
region.addNote(kgNote);
|
||||||
regionData.notes.forEach(midiNote => {
|
|
||||||
const noteId = generateUniqueId('KGMidiNote');
|
|
||||||
const relativeStartBeat = midiNote.startBeat - regionData.startBeat;
|
|
||||||
const relativeEndBeat = midiNote.endBeat - regionData.startBeat;
|
|
||||||
|
|
||||||
const kgNote = new KGMidiNote(
|
|
||||||
noteId,
|
|
||||||
relativeStartBeat,
|
|
||||||
relativeEndBeat,
|
|
||||||
midiNote.pitch,
|
|
||||||
midiNote.velocity
|
|
||||||
);
|
|
||||||
|
|
||||||
region.addNote(kgNote);
|
|
||||||
});
|
|
||||||
|
|
||||||
kgTrack.addRegion(region);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
kgTrack.addRegion(region);
|
||||||
|
|
||||||
// Add track to project
|
// Add track to project
|
||||||
project.getTracks().push(kgTrack);
|
project.getTracks().push(kgTrack);
|
||||||
addedTrackCount += 1;
|
addedTrackCount += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (importedTrackEndBeats.length > 0) {
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
const requiredBars = Math.ceil(Math.max(...importedTrackEndBeats) / beatsPerBar);
|
||||||
|
if (requiredBars > project.getMaxBars()) {
|
||||||
|
project.setMaxBars(requiredBars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -850,12 +857,6 @@ interface ParsedMidiNote {
|
|||||||
velocity: number;
|
velocity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegionData {
|
|
||||||
startBeat: number;
|
|
||||||
length: number;
|
|
||||||
notes: ParsedMidiNote[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a MIDI binary file into a structured format
|
* Parses a MIDI binary file into a structured format
|
||||||
*/
|
*/
|
||||||
@@ -1287,44 +1288,3 @@ function getKeySignatureFromMidi(sharpsFlats: number, majorMinor: number): KeySi
|
|||||||
// Default to C major
|
// Default to C major
|
||||||
return 'C major';
|
return 'C major';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Groups MIDI notes into regions based on timing
|
|
||||||
*/
|
|
||||||
function groupNotesIntoRegions(notes: ParsedMidiNote[], timeSignature: TimeSignature): RegionData[] {
|
|
||||||
if (notes.length === 0) return [];
|
|
||||||
|
|
||||||
// Sort notes by start time
|
|
||||||
const sortedNotes = [...notes].sort((a, b) => a.startBeat - b.startBeat);
|
|
||||||
|
|
||||||
const regions: RegionData[] = [];
|
|
||||||
const beatsPerBar = timeSignature.numerator;
|
|
||||||
const regionLengthInBeats = beatsPerBar * 4; // 4 bars per region
|
|
||||||
|
|
||||||
// Find the range of all notes
|
|
||||||
const firstNoteBeat = Math.floor(sortedNotes[0].startBeat);
|
|
||||||
const lastNoteBeat = Math.ceil(Math.max(...sortedNotes.map(n => n.endBeat)));
|
|
||||||
|
|
||||||
// Create regions to cover all notes
|
|
||||||
for (let regionStart = Math.floor(firstNoteBeat / regionLengthInBeats) * regionLengthInBeats;
|
|
||||||
regionStart < lastNoteBeat;
|
|
||||||
regionStart += regionLengthInBeats) {
|
|
||||||
|
|
||||||
const regionEnd = regionStart + regionLengthInBeats;
|
|
||||||
|
|
||||||
// Find notes that belong to this region
|
|
||||||
const regionNotes = sortedNotes.filter(note =>
|
|
||||||
note.startBeat >= regionStart && note.startBeat < regionEnd
|
|
||||||
);
|
|
||||||
|
|
||||||
if (regionNotes.length > 0) {
|
|
||||||
regions.push({
|
|
||||||
startBeat: regionStart,
|
|
||||||
length: regionLengthInBeats,
|
|
||||||
notes: regionNotes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user