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; -}