import { describe, expect, it } from "vitest"; import { constantTempoMidi, cropMidi, dropChannel, encodeMidi, exportMidiCsv, exportMidiJson, noteSpans, parseMidi, quantizeMidi, remapChannel, summarizeMidi, transposeMidi, type MidiDocument, } from "../../src/core/midi"; function documentFixture(): MidiDocument { return { name: "fixture.mid", format: 1, division: 480, warnings: [], tracks: [ { name: "Conductor", events: [ { kind: "meta", tick: 0, order: 0, metaType: 0x51, data: Uint8Array.of(0x07, 0xa1, 0x20), }, { kind: "meta", tick: 0, order: 1, metaType: 0x58, data: Uint8Array.of(4, 2, 24, 8), }, { kind: "meta", tick: 0, order: 2, metaType: 0x59, data: Uint8Array.of(0, 0), }, { kind: "meta", tick: 120, order: 3, metaType: 0x01, data: new TextEncoder().encode("=formula"), }, ], }, { name: "Notes", events: [ { kind: "channel", tick: 35, order: 4, status: 0x9, channel: 0, data: Uint8Array.of(60, 100), }, { kind: "channel", tick: 455, order: 5, status: 0x8, channel: 0, data: Uint8Array.of(60, 0), }, { kind: "channel", tick: 500, order: 6, status: 0x9, channel: 1, data: Uint8Array.of(64, 90), }, { kind: "channel", tick: 970, order: 7, status: 0x8, channel: 1, data: Uint8Array.of(64, 0), }, ], }, ], }; } describe("Standard MIDI parsing and encoding", () => { it("parses type 0 running status and round-trips notes", () => { const bytes = Uint8Array.of( 0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 0, 0, 1, 1, 0xe0, 0x4d, 0x54, 0x72, 0x6b, 0, 0, 0, 12, 0, 0x90, 60, 100, 0x83, 0x60, 60, 0, 0, 0xff, 0x2f, 0, ); const parsed = parseMidi("running.mid", bytes); expect(parsed.format).toBe(0); expect(noteSpans(parsed)[0]).toMatchObject({ note: 60, startTick: 0, endTick: 480, }); expect(noteSpans(parseMidi("again.mid", encodeMidi(parsed)))).toHaveLength( 1, ); }); it("summarizes tempo, meter and key and emits safe reports", () => { const parsed = parseMidi("fixture.mid", encodeMidi(documentFixture())); const summary = summarizeMidi(parsed); expect(summary).toMatchObject({ tracks: 2, notes: 2, durationTicks: 970 }); expect(summary.tempos[0]?.bpm).toBe(120); expect(summary.timeSignatures[0]?.value).toBe("4/4"); expect(summary.keySignatures[0]?.value).toBe("C"); expect(exportMidiCsv(parsed)).toContain("'=formula"); expect(JSON.parse(exportMidiJson(parsed)).tracks).toHaveLength(2); }); it("uses inert filler metadata for representable long silent gaps", () => { const source: MidiDocument = { name: "gap.mid", format: 0, division: 480, warnings: [], tracks: [ { name: "Gap", events: [ { kind: "channel", tick: 0x10000000, order: 0, status: 0xc, channel: 0, data: Uint8Array.of(1), }, ], }, ], }; const parsed = parseMidi("gap.mid", encodeMidi(source)); expect( parsed.tracks[0]?.events.find((event) => event.kind === "channel")?.tick, ).toBe(0x10000000); }); it("rejects unsupported formats, malformed VLQs and trailing bytes", () => { const typeTwo = encodeMidi(documentFixture()); typeTwo[9] = 2; expect(() => parseMidi("type2.mid", typeTwo)).toThrow(/types 0 and 1/u); const malformed = Uint8Array.of( 0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 0, 0, 1, 1, 0xe0, 0x4d, 0x54, 0x72, 0x6b, 0, 0, 0, 5, 0x81, 0x80, 0x80, 0x80, 0, ); expect(() => parseMidi("vlq.mid", malformed)).toThrow(/VLQ exceeds/u); const valid = encodeMidi(documentFixture()); const trailing = new Uint8Array(valid.length + 1); trailing.set(valid); expect(() => parseMidi("trailing.mid", trailing)).toThrow(/follow/u); }); }); describe("MIDI editing operations", () => { it("transposes, quantizes and replaces tempo without mutating source", () => { const source = documentFixture(); const transposed = transposeMidi(source, 12); expect(noteSpans(transposed).map((note) => note.note)).toEqual([72, 76]); expect(noteSpans(source).map((note) => note.note)).toEqual([60, 64]); const quantized = quantizeMidi(source, 120); expect( noteSpans(quantized).map((note) => [note.startTick, note.endTick]), ).toEqual([ [0, 480], [480, 960], ]); expect( summarizeMidi(constantTempoMidi(source, 90)).tempos[0]?.bpm, ).toBeCloseTo(90, 3); }); it("crops overlapping notes and remaps or drops a channel", () => { const cropped = cropMidi(documentFixture(), 240, 720); expect( noteSpans(cropped).map((note) => [note.startTick, note.endTick]), ).toEqual([ [0, 215], [260, 480], ]); expect( summarizeMidi(remapChannel(documentFixture(), 2, 1)).channels, ).toEqual([1]); expect(summarizeMidi(dropChannel(documentFixture(), 1)).channels).toEqual([ 2, ]); expect(() => transposeMidi(documentFixture(), 100)).toThrow( /outside MIDI/u, ); }); });