Release MIDI Tools v0.1.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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