266 lines
6.1 KiB
TypeScript
266 lines
6.1 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|