Release MIDI Tools v0.1.0

This commit is contained in:
2026-09-01 14:22:42 +02:00
commit 4b51a6d903
58 changed files with 10240 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test";
for (const path of ["/", "/deep/nested/midi/"]) {
test(`inspects and edits MIDI locally at ${path}`, async ({ page }) => {
const external: string[] = [];
page.on("request", (request) => {
if (!request.url().startsWith("http://127.0.0.1:4212"))
external.push(request.url());
});
await page.goto(path);
await expect(
page.getByRole("heading", {
name: "See, edit and hear the timeline locally.",
}),
).toBeVisible();
await expect(
page.getByRole("img", { name: /Piano roll with 3 notes/u }),
).toBeVisible();
await page.getByLabel("Semitones").fill("12");
await page.getByRole("button", { name: "Apply transpose" }).click();
await expect(page.getByRole("status")).toContainText("transposed");
await expect(
page.getByRole("heading", { name: "Event inspector" }),
).toBeVisible();
expect(external).toEqual([]);
});
}
test("keeps the installed MIDI application available offline", async ({
page,
context,
}) => {
await page.goto("/");
await page.evaluate(async () => navigator.serviceWorker.ready);
await page.reload();
await context.setOffline(true);
try {
await page.reload();
await expect(
page.getByRole("heading", {
name: "See, edit and hear the timeline locally.",
}),
).toBeVisible();
} finally {
await context.setOffline(false);
}
});
+68
View File
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Workbench } from "../../src/components/Workbench";
let closeCount = 0;
class FakeAudioContext {
currentTime = 0;
destination = {} as AudioDestinationNode;
async resume(): Promise<void> {}
createGain() {
return {
gain: {
value: 0,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
};
}
createOscillator() {
return {
type: "sine",
frequency: { value: 0 },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
}
async close(): Promise<void> {
closeCount += 1;
}
}
describe("MIDI Workbench", () => {
beforeEach(() => {
closeCount = 0;
vi.stubGlobal("AudioContext", FakeAudioContext);
});
afterEach(() => vi.unstubAllGlobals());
it("shows the example and applies and undoes an edit", async () => {
const user = userEvent.setup();
render(<Workbench />);
expect(
screen.getByRole("img", { name: /Piano roll with 3 notes/u }),
).toBeVisible();
await user.clear(screen.getByLabelText("Semitones"));
await user.type(screen.getByLabelText("Semitones"), "12");
await user.click(screen.getByRole("button", { name: "Apply transpose" }));
expect(screen.getByRole("status")).toHaveTextContent("transposed");
await user.click(screen.getByRole("button", { name: "Undo edit" }));
expect(screen.getByRole("status")).toHaveTextContent("undone");
});
it("closes the active audio context on immediate stop", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Play from start" }));
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Playing 3"),
);
await user.click(screen.getByRole("button", { name: "Stop now" }));
expect(closeCount).toBe(1);
expect(screen.getByRole("status")).toHaveTextContent("stopped immediately");
});
});
+265
View File
@@ -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,
);
});
});