Release MIDI Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user