Files
midi-tools/tests/core/midi.test.ts
T
zemion 06e095a2eb
Verify / verify (push) Canceled after 0s
Release MIDI Tools 0.2.0
2026-09-02 09:14:48 +02:00

295 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
constantTempoMidi,
cropMidi,
deleteNote,
dropChannel,
encodeMidi,
editNote,
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,
);
});
it("edits and deletes one paired note without mutating its neighbours", () => {
const source = documentFixture();
const first = noteSpans(source)[0]!;
const edited = editNote(source, first.onOrder, {
note: 61,
velocity: 77,
startTick: 100,
endTick: 700,
});
expect(noteSpans(edited)[0]).toMatchObject({
note: 61,
velocity: 77,
startTick: 100,
endTick: 700,
});
expect(noteSpans(source)[0]?.note).toBe(60);
expect(noteSpans(deleteNote(edited, first.onOrder))).toHaveLength(1);
expect(() =>
editNote(source, first.onOrder, {
note: 60,
velocity: 100,
startTick: 20,
endTick: 20,
}),
).toThrow(/after its start/u);
});
});