diff --git a/src/components/piano-roll/PianoGridHeader.test.tsx b/src/components/piano-roll/PianoGridHeader.test.tsx
index 35375e5..fb299bd 100644
--- a/src/components/piano-roll/PianoGridHeader.test.tsx
+++ b/src/components/piano-roll/PianoGridHeader.test.tsx
@@ -28,10 +28,12 @@ function renderHeader({
hasPianoKeys = true,
scrollLeft = 0,
paddingLeft = hasPianoKeys ? '60px' : '0px',
+ scrollContainerLeft = 100,
}: {
hasPianoKeys?: boolean;
scrollLeft?: number;
paddingLeft?: string;
+ scrollContainerLeft?: number;
} = {}) {
const view = render(
@@ -48,18 +50,33 @@ function renderHeader({
writable: true,
});
+ Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({
+ left: scrollContainerLeft,
+ top: 0,
+ right: scrollContainerLeft + 500,
+ bottom: 20,
+ width: 500,
+ height: 20,
+ x: scrollContainerLeft,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
header.style.paddingLeft = paddingLeft;
Object.defineProperty(header, 'getBoundingClientRect', {
configurable: true,
value: () => ({
- left: 100,
+ left: scrollContainerLeft - scrollLeft,
top: 0,
- right: 600,
+ right: scrollContainerLeft - scrollLeft + 500,
bottom: 20,
width: 500,
height: 20,
- x: 100,
+ x: scrollContainerLeft - scrollLeft,
y: 0,
toJSON: () => ({}),
}),
@@ -88,7 +105,7 @@ describe('PianoGridHeader', () => {
expect(requestMainContentScroll).toHaveBeenCalledWith(3);
});
- it('includes the note scroll offset when seeking after horizontal scroll', () => {
+ it('seeks to the same beat after horizontal scroll when the header rect already shifts with content', () => {
const { header } = renderHeader({ scrollLeft: 160 });
fireEvent.click(header, { clientX: 280 });
@@ -118,4 +135,13 @@ describe('PianoGridHeader', () => {
expect(setPlayheadPosition).toHaveBeenCalledWith(5);
expect(requestMainContentScroll).not.toHaveBeenCalled();
});
+
+ it('ignores clicks inside the visible piano-key gutter before it fully scrolls out of view', () => {
+ const { header } = renderHeader({ scrollLeft: 20 });
+
+ fireEvent.click(header, { clientX: 110 });
+
+ expect(setPlayheadPosition).not.toHaveBeenCalled();
+ expect(requestMainContentScroll).not.toHaveBeenCalled();
+ });
});
diff --git a/src/components/piano-roll/PianoGridHeader.tsx b/src/components/piano-roll/PianoGridHeader.tsx
index a8f9c4b..8a5c9fa 100644
--- a/src/components/piano-roll/PianoGridHeader.tsx
+++ b/src/components/piano-roll/PianoGridHeader.tsx
@@ -28,9 +28,9 @@ const PianoGridHeader: React.FC
= ({
if (!headerElementRef.current) return null;
const headerElement = headerElementRef.current;
- const rect = headerElement.getBoundingClientRect();
- const relativeX = clientX - rect.left;
const scrollContainer = headerElement.closest('.piano-roll-note-scroll') as HTMLElement | null;
+ const referenceRect = scrollContainer?.getBoundingClientRect() ?? headerElement.getBoundingClientRect();
+ const relativeX = clientX - referenceRect.left;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const leftGutter = parseFloat(getComputedStyle(headerElement).paddingLeft) || 0;
const adjustedX = relativeX + scrollLeft - leftGutter;
diff --git a/src/core/KGDebugger.test.ts b/src/core/KGDebugger.test.ts
new file mode 100644
index 0000000..b42ba0e
--- /dev/null
+++ b/src/core/KGDebugger.test.ts
@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./KGCore', () => ({
+ KGCore: {
+ instance: () => ({
+ getSelectedItems: () => [],
+ getCurrentProject: () => ({}),
+ }),
+ },
+}));
+
+vi.mock('./region/KGMidiRegion', () => ({
+ KGMidiRegion: class {},
+}));
+
+vi.mock('../util/abcNotationUtil', () => ({
+ convertRegionToABCNotation: vi.fn(),
+ convertBeatRangeChordProgressionToABCNotation: vi.fn(),
+}));
+
+vi.mock('../util/xmlUtil', () => ({
+ extractXMLFromString: vi.fn(),
+}));
+
+vi.mock('../agent/core/AgentCore', () => ({
+ AgentCore: class {},
+}));
+
+vi.mock('../agent/tools', () => ({
+ AVAILABLE_TOOLS: {},
+}));
+
+vi.mock('../stores/projectStore', () => ({
+ useProjectStore: {
+ getState: () => ({
+ activeRegionId: null,
+ tracks: [],
+ }),
+ },
+}));
+
+import { KGDebugger } from './KGDebugger';
+
+class MockFileSystemFileHandle {
+ public kind = 'file' as const;
+
+ constructor(
+ public name: string,
+ private readonly content: string,
+ private readonly lastModified: number = Date.now(),
+ ) {}
+
+ async getFile(): Promise {
+ return new File([this.content], this.name, { lastModified: this.lastModified });
+ }
+}
+
+class MockFileSystemDirectoryHandle {
+ public kind = 'directory' as const;
+ private readonly children = new Map();
+
+ constructor(public name: string) {}
+
+ async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise {
+ let child = this.children.get(name);
+ if (!child || child.kind !== 'directory') {
+ if (!options?.create) {
+ throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
+ }
+ child = new MockFileSystemDirectoryHandle(name);
+ this.children.set(name, child);
+ }
+ return child;
+ }
+
+ async getFileHandle(name: string, options?: { create?: boolean }): Promise {
+ let child = this.children.get(name);
+ if (!child || child.kind !== 'file') {
+ if (!options?.create) {
+ throw new DOMException(`File "${name}" not found`, 'NotFoundError');
+ }
+ child = new MockFileSystemFileHandle(name, '');
+ this.children.set(name, child);
+ }
+ return child;
+ }
+
+ addDirectory(name: string): MockFileSystemDirectoryHandle {
+ const dir = new MockFileSystemDirectoryHandle(name);
+ this.children.set(name, dir);
+ return dir;
+ }
+
+ addFile(name: string, content: string): MockFileSystemFileHandle {
+ const file = new MockFileSystemFileHandle(name, content);
+ this.children.set(name, file);
+ return file;
+ }
+
+ async *values(): AsyncIterableIterator {
+ for (const child of this.children.values()) {
+ yield child;
+ }
+ }
+}
+
+const mockRoot = new MockFileSystemDirectoryHandle('root');
+
+vi.stubGlobal('navigator', {
+ ...navigator,
+ storage: {
+ getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
+ },
+});
+
+describe('KGDebugger OPFS du', () => {
+ let debuggerInstance: KGDebugger;
+ let logSpy: ReturnType;
+ let errorSpy: ReturnType;
+
+ beforeEach(() => {
+ (KGDebugger as unknown as { _instance: KGDebugger | null })._instance = null;
+ const children = (mockRoot as unknown as {
+ children: Map;
+ }).children;
+ children.clear();
+
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ debuggerInstance = KGDebugger.instance();
+ logSpy.mockClear();
+ errorSpy.mockClear();
+ });
+
+ it('reports 0 for an empty current directory', async () => {
+ await debuggerInstance.opfs('du');
+
+ expect(logSpy).toHaveBeenCalledWith('0 .');
+ });
+
+ it('reports file sizes for direct children in the current directory', async () => {
+ mockRoot.addFile('beat.txt', '12345');
+ mockRoot.addFile('melody.mid', '123456789');
+
+ await debuggerInstance.opfs('du');
+
+ expect(logSpy.mock.calls).toEqual([
+ ['5 beat.txt'],
+ ['9 melody.mid'],
+ ]);
+ });
+
+ it('reports recursive directory sizes and appends trailing slashes', async () => {
+ const projects = mockRoot.addDirectory('projects');
+ projects.addFile('meta.json', '{}');
+ const media = projects.addDirectory('media');
+ media.addFile('take.wav', '1234567');
+
+ await debuggerInstance.opfs('du');
+
+ expect(logSpy).toHaveBeenCalledWith('9 projects/');
+ });
+
+ it('supports relative and absolute paths', async () => {
+ const songs = mockRoot.addDirectory('songs');
+ const demos = songs.addDirectory('demos');
+ demos.addFile('idea.txt', '1234');
+
+ await debuggerInstance.opfs('cd songs');
+ logSpy.mockClear();
+
+ await debuggerInstance.opfs('du demos');
+ await debuggerInstance.opfs('du /songs/demos');
+
+ expect(logSpy.mock.calls).toEqual([
+ ['4 demos/'],
+ ['4 demos/'],
+ ]);
+ });
+
+ it('keeps ls output unchanged for directories', async () => {
+ mockRoot.addDirectory('archive');
+ mockRoot.addFile('notes.txt', '1234');
+
+ await debuggerInstance.opfs('ls');
+
+ expect(logSpy.mock.calls).toContainEqual(['total 2 (/)']);
+ expect(logSpy.mock.calls).toContainEqual(['drwxr-xr-x - - archive/']);
+ expect(logSpy.mock.calls).toContainEqual([expect.stringMatching(/^-rw-r--r--\s+4\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+notes\.txt$/)]);
+ });
+
+ it('reports missing paths with the du-specific error message', async () => {
+ await debuggerInstance.opfs('du missing');
+
+ expect(errorSpy).toHaveBeenCalledWith('opfs: du: no such file or directory: missing');
+ });
+});
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index a08763c..a2cc87a 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -676,7 +676,7 @@ export class KGDebugger {
*/
public async startShell(): Promise {
console.log('📟 OPFS Interactive Shell');
- console.log(' Commands: pwd, ls, cd , cat , dl , rm ');
+ console.log(' Commands: pwd, ls, du [path], cd , cat , dl , rm ');
console.log(' Type "exit" or click Cancel to quit.\n');
let lastOutput = '';
@@ -731,6 +731,7 @@ export class KGDebugger {
* Supported commands:
* pwd — print current directory
* ls — list files/folders (like ls -lla)
+ * du [path] — print recursive file/folder sizes in bytes
* cd — change directory (supports .., /, relative, and quoted paths)
* cat — print file contents
* dl — download a file to your local machine
@@ -739,6 +740,7 @@ export class KGDebugger {
* Usage in console:
* await KGDebugger.opfs('pwd')
* await KGDebugger.opfs('ls')
+ * await KGDebugger.opfs('du')
* await KGDebugger.opfs('cd projects')
* await KGDebugger.opfs('cat project.json')
*/
@@ -758,6 +760,10 @@ export class KGDebugger {
await this.opfsLs();
break;
+ case 'du':
+ await this.opfsDu(arg);
+ break;
+
case 'cd':
await this.opfsCd(arg);
break;
@@ -776,7 +782,7 @@ export class KGDebugger {
default:
console.log(`opfs: command not found: ${cmd}`);
- console.log('Available commands: pwd, ls, cd , cat , dl , rm ');
+ console.log('Available commands: pwd, ls, du [path], cd , cat , dl , rm ');
}
} catch (error) {
console.error(`opfs: ${error}`);
@@ -840,6 +846,103 @@ export class KGDebugger {
}
}
+ private async opfsDu(path: string): Promise {
+ if (!path) {
+ const dir = await this.opfsResolveCwd();
+ const entries: Array<{ name: string; size: number }> = [];
+
+ for await (const entry of dir.values()) {
+ entries.push({
+ name: entry.kind === 'directory' ? `${entry.name}/` : entry.name,
+ size: await this.opfsGetEntrySize(entry),
+ });
+ }
+
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+
+ if (entries.length === 0) {
+ console.log('0 .');
+ return;
+ }
+
+ for (const entry of entries) {
+ console.log(`${entry.size} ${entry.name}`);
+ }
+ return;
+ }
+
+ try {
+ const resolved = await this.opfsResolvePath(path);
+ const displayName = resolved.kind === 'directory'
+ ? `${resolved.name}/`
+ : resolved.name;
+ const size = await this.opfsGetEntrySize(resolved);
+ console.log(`${size} ${displayName}`);
+ } catch {
+ console.error(`opfs: du: no such file or directory: ${path}`);
+ }
+ }
+
+ private async opfsResolvePath(path: string): Promise {
+ const { parentDir, name } = await this.opfsResolveParentAndName(path);
+
+ try {
+ return await parentDir.getDirectoryHandle(name);
+ } catch {
+ return parentDir.getFileHandle(name);
+ }
+ }
+
+ private async opfsResolveParentAndName(path: string): Promise<{ parentDir: FileSystemDirectoryHandle; name: string }> {
+ if (!path || path === '') {
+ throw new Error('missing path');
+ }
+
+ const segments = this.opfsResolvePathSegments(path);
+ const name = segments.pop();
+
+ if (!name) {
+ throw new Error('missing path');
+ }
+
+ let parentDir = await navigator.storage.getDirectory();
+ for (const segment of segments) {
+ parentDir = await parentDir.getDirectoryHandle(segment);
+ }
+
+ return { parentDir, name };
+ }
+
+ private opfsResolvePathSegments(path: string): string[] {
+ const rawSegments = path.startsWith('/')
+ ? path.split('/').filter(Boolean)
+ : [...this.opfsCwd, ...path.split('/').filter(Boolean)];
+
+ const resolved: string[] = [];
+ for (const segment of rawSegments) {
+ if (segment === '.') continue;
+ if (segment === '..') {
+ resolved.pop();
+ } else {
+ resolved.push(segment);
+ }
+ }
+
+ return resolved;
+ }
+
+ private async opfsGetEntrySize(entry: FileSystemDirectoryHandle | FileSystemFileHandle): Promise {
+ if (entry.kind === 'file') {
+ return (await entry.getFile()).size;
+ }
+
+ let total = 0;
+ for await (const child of entry.values()) {
+ total += await this.opfsGetEntrySize(child);
+ }
+ return total;
+ }
+
private async opfsCd(path: string): Promise {
if (!path || path === '') {
// cd with no args goes to root
@@ -847,29 +950,12 @@ export class KGDebugger {
return;
}
- let segments: string[];
-
if (path === '/') {
this.opfsCwd = [];
return;
- } else if (path.startsWith('/')) {
- // Absolute path
- segments = path.split('/').filter(Boolean);
- } else {
- // Relative path
- segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)];
}
- // Resolve . and ..
- const resolved: string[] = [];
- for (const seg of segments) {
- if (seg === '.') continue;
- if (seg === '..') {
- resolved.pop();
- } else {
- resolved.push(seg);
- }
- }
+ const resolved = this.opfsResolvePathSegments(path);
// Verify the path exists
let dir = await navigator.storage.getDirectory();
diff --git a/src/util/midiUtil.test.ts b/src/util/midiUtil.test.ts
index 7229894..51e38a7 100644
--- a/src/util/midiUtil.test.ts
+++ b/src/util/midiUtil.test.ts
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import {
beatsToBar,
+ convertMidiToProject,
+ convertProjectToMidi,
formatMidiEventLength,
formatMidiEventPosition,
MIDI_EVENT_TICKS_PER_BEAT,
@@ -13,6 +15,10 @@ import {
pianoRollIndexToPitch,
noteNameToPitch
} 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('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', () => {
it('should handle negative values gracefully', () => {
expect(() => pitchToNoteNameString(-1)).not.toThrow();
diff --git a/src/util/midiUtil.ts b/src/util/midiUtil.ts
index de3a926..9c91de4 100644
--- a/src/util/midiUtil.ts
+++ b/src/util/midiUtil.ts
@@ -761,6 +761,7 @@ export const convertMidiToProject = (midiData: Uint8Array, existingProject?: KGP
// Get existing tracks to calculate proper track indices
const existingTracks = project.getTracks();
const startingTrackIndex = existingTracks.length;
+ const importedTrackEndBeats: number[] = [];
// Convert MIDI tracks to KGSP tracks
let addedTrackCount = 0;
@@ -782,45 +783,51 @@ export const convertMidiToProject = (midiData: Uint8Array, existingProject?: KGP
const kgTrack = new KGMidiTrack(trackName, trackId, instrument);
kgTrack.setTrackIndex(actualTrackIndex);
- // Group notes into regions (every 16 beats for now, could be more sophisticated)
- const regions = groupNotesIntoRegions(midiTrack.notes, project.getTimeSignature());
-
- regions.forEach((regionData, regionIndex) => {
- const regionId = generateUniqueId('KGMidiRegion');
- const regionName = `${trackName} Region ${regionIndex + 1}`;
- const region = new KGMidiRegion(
- regionId,
- trackId.toString(),
- actualTrackIndex,
- regionName,
- regionData.startBeat,
- regionData.length
+ const regionStartBeat = Math.min(...midiTrack.notes.map(note => note.startBeat));
+ 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
+ );
+
+ // Store imported notes relative to the region start while preserving their timing.
+ midiTrack.notes.forEach(midiNote => {
+ const noteId = generateUniqueId('KGMidiNote');
+ const relativeStartBeat = midiNote.startBeat - regionStartBeat;
+ const relativeEndBeat = midiNote.endBeat - regionStartBeat;
+
+ const kgNote = new KGMidiNote(
+ noteId,
+ relativeStartBeat,
+ relativeEndBeat,
+ midiNote.pitch,
+ midiNote.velocity
);
-
- // Convert MIDI notes to KG notes (with relative timing)
- 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);
+
+ region.addNote(kgNote);
});
+
+ kgTrack.addRegion(region);
// Add track to project
project.getTracks().push(kgTrack);
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;
};
@@ -850,12 +857,6 @@ interface ParsedMidiNote {
velocity: number;
}
-interface RegionData {
- startBeat: number;
- length: number;
- notes: ParsedMidiNote[];
-}
-
/**
* Parses a MIDI binary file into a structured format
*/
@@ -1287,44 +1288,3 @@ function getKeySignatureFromMidi(sharpsFlats: number, majorMinor: number): KeySi
// Default to 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;
-}