Merge pull request #56 from KGAudioLab/feat/2026-06-30-misc
Feat/2026 06 30 misc
This commit is contained in:
@@ -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(
|
||||
<div className="piano-roll-note-scroll">
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,9 +28,9 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
|
||||
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;
|
||||
|
||||
@@ -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<File> {
|
||||
return new File([this.content], this.name, { lastModified: this.lastModified });
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemDirectoryHandle {
|
||||
public kind = 'directory' as const;
|
||||
private readonly children = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
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<MockFileSystemFileHandle> {
|
||||
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<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
|
||||
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<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
(KGDebugger as unknown as { _instance: KGDebugger | null })._instance = null;
|
||||
const children = (mockRoot as unknown as {
|
||||
children: Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>;
|
||||
}).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');
|
||||
});
|
||||
});
|
||||
+106
-20
@@ -676,7 +676,7 @@ export class KGDebugger {
|
||||
*/
|
||||
public async startShell(): Promise<void> {
|
||||
console.log('📟 OPFS Interactive Shell');
|
||||
console.log(' Commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
|
||||
console.log(' Commands: pwd, ls, du [path], cd <path>, cat <file>, dl <file>, rm <name>');
|
||||
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 <path> — change directory (supports .., /, relative, and quoted paths)
|
||||
* cat <file> — print file contents
|
||||
* dl <file> — 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 <path>, cat <file>, dl <file>, rm <name>');
|
||||
console.log('Available commands: pwd, ls, du [path], cd <path>, cat <file>, dl <file>, rm <name>');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`opfs: ${error}`);
|
||||
@@ -840,6 +846,103 @@ export class KGDebugger {
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsDu(path: string): Promise<void> {
|
||||
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<FileSystemDirectoryHandle | FileSystemFileHandle> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+39
-79
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user