@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
for (const path of ["/", "/deep/nested/midi/"]) {
|
||||
test(`inspects and edits MIDI locally at ${path}`, async ({ page }) => {
|
||||
@@ -26,6 +27,40 @@ for (const path of ["/", "/deep/nested/midi/"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("parses and exports through the MIDI worker", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Open MIDI file").setInputFiles({
|
||||
name: "worker.mid",
|
||||
mimeType: "audio/midi",
|
||||
buffer: midiFixture(),
|
||||
});
|
||||
await expect(page.getByRole("status")).toContainText("Parsed type 0 MIDI");
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download MIDI" }).click();
|
||||
expect((await pending).suggestedFilename()).toBe("worker.mid");
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"MIDI export generated in the worker",
|
||||
);
|
||||
});
|
||||
|
||||
test("edits a paired note and inspects a local SoundFont", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Select first note" }).click();
|
||||
await page.getByLabel("Pitch (0–127)").fill("61");
|
||||
await page.getByLabel("Velocity").fill("77");
|
||||
await page.getByRole("button", { name: "Apply note edit" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("note updated");
|
||||
await page.getByLabel("Open SoundFont file").setInputFiles({
|
||||
name: "fixture.sf2",
|
||||
mimeType: "audio/x-soundfont",
|
||||
buffer: soundFontFixture(),
|
||||
});
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Inspected the local SoundFont structure",
|
||||
);
|
||||
await expect(page.getByText("Fixture bank", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the installed MIDI application available offline", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -45,3 +80,31 @@ test("keeps the installed MIDI application available offline", async ({
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
|
||||
function midiFixture(): Buffer {
|
||||
return Buffer.from([
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
function soundFontFixture(): Buffer {
|
||||
const chunk = (id: string, data: Buffer) => {
|
||||
const output = Buffer.alloc(8 + data.length + (data.length & 1));
|
||||
output.write(id, 0, "ascii");
|
||||
output.writeUInt32LE(data.length, 4);
|
||||
data.copy(output, 8);
|
||||
return output;
|
||||
};
|
||||
const list = (id: string, ...children: Buffer[]) =>
|
||||
chunk("LIST", Buffer.concat([Buffer.from(id, "ascii"), ...children]));
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("sfbk", "ascii"),
|
||||
list("INFO", chunk("INAM", Buffer.from("Fixture bank\0"))),
|
||||
list("pdta", chunk("phdr", Buffer.alloc(76))),
|
||||
]);
|
||||
const output = Buffer.alloc(8);
|
||||
output.write("RIFF", 0, "ascii");
|
||||
output.writeUInt32LE(body.length, 4);
|
||||
return Buffer.concat([output, body]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/midi/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -49,7 +49,9 @@ describe("MIDI Workbench", () => {
|
||||
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 waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("transposed"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Undo edit" }));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("undone");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectSoundFont } from "../../src/core/soundfont";
|
||||
import { allNotesOffMessages, webMidiSchedule } from "../../src/core/web-midi";
|
||||
import type { MidiDocument } from "../../src/core/midi";
|
||||
|
||||
const midi: MidiDocument = {
|
||||
name: "local.mid",
|
||||
format: 0,
|
||||
division: 480,
|
||||
warnings: [],
|
||||
tracks: [
|
||||
{
|
||||
name: "Notes",
|
||||
events: [
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 0,
|
||||
order: 0,
|
||||
status: 9,
|
||||
channel: 2,
|
||||
data: Uint8Array.of(64, 99),
|
||||
},
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 480,
|
||||
order: 1,
|
||||
status: 8,
|
||||
channel: 2,
|
||||
data: Uint8Array.of(64, 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("explicit local playback helpers", () => {
|
||||
it("creates channel-only Web MIDI schedules and panic messages", () => {
|
||||
expect(webMidiSchedule(midi)).toEqual([
|
||||
{ offsetMs: 0, data: [0x92, 64, 99] },
|
||||
{ offsetMs: 500, data: [0x82, 64, 0] },
|
||||
]);
|
||||
expect(allNotesOffMessages()).toHaveLength(16);
|
||||
expect(allNotesOffMessages()[2]).toEqual([0xb2, 123, 0]);
|
||||
});
|
||||
|
||||
it("inspects a bounded RIFF SoundFont without synthesizing it", () => {
|
||||
const info = list("INFO", chunk("INAM", text("Fixture bank\0")));
|
||||
const phdr = chunk("phdr", new Uint8Array(38 * 2));
|
||||
const pdta = list("pdta", phdr);
|
||||
const body = bytes(text("sfbk"), info, pdta);
|
||||
const fixture = bytes(text("RIFF"), le32(body.length), body);
|
||||
expect(inspectSoundFont("fixture.sf2", fixture)).toMatchObject({
|
||||
bankName: "Fixture bank",
|
||||
presets: 1,
|
||||
sections: ["INFO", "pdta"],
|
||||
});
|
||||
fixture[8] = 0;
|
||||
expect(() => inspectSoundFont("bad.sf2", fixture)).toThrow(/sfbk/u);
|
||||
});
|
||||
});
|
||||
|
||||
function list(type: string, ...children: Uint8Array[]): Uint8Array {
|
||||
return chunk("LIST", bytes(text(type), ...children));
|
||||
}
|
||||
|
||||
function chunk(id: string, data: Uint8Array): Uint8Array {
|
||||
return bytes(
|
||||
text(id),
|
||||
le32(data.length),
|
||||
data,
|
||||
...(data.length & 1 ? [Uint8Array.of(0)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function bytes(...parts: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(
|
||||
parts.reduce((sum, part) => sum + part.length, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function text(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function le32(value: number): Uint8Array {
|
||||
return Uint8Array.of(value, value >>> 8, value >>> 16, value >>> 24);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeMidiTask } from "../../src/core/midi-task";
|
||||
import { encodeMidi, noteSpans, type MidiDocument } from "../../src/core/midi";
|
||||
|
||||
const document: MidiDocument = {
|
||||
name: "worker.mid",
|
||||
format: 0,
|
||||
division: 480,
|
||||
warnings: [],
|
||||
tracks: [
|
||||
{
|
||||
name: "Notes",
|
||||
events: [
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 0,
|
||||
order: 0,
|
||||
status: 9,
|
||||
channel: 0,
|
||||
data: Uint8Array.of(60, 100),
|
||||
},
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 480,
|
||||
order: 1,
|
||||
status: 8,
|
||||
channel: 0,
|
||||
data: Uint8Array.of(60, 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("MIDI worker task protocol", () => {
|
||||
it("parses, transforms and exports without UI state", () => {
|
||||
const encoded = encodeMidi(document);
|
||||
const parsed = executeMidiTask({
|
||||
kind: "parse",
|
||||
name: "worker.mid",
|
||||
bytes: Uint8Array.from(encoded).buffer,
|
||||
});
|
||||
expect(parsed.kind).toBe("document");
|
||||
if (parsed.kind !== "document") return;
|
||||
const transformed = executeMidiTask({
|
||||
kind: "operation",
|
||||
document: parsed.document,
|
||||
operation: { kind: "transpose", semitones: 12 },
|
||||
});
|
||||
expect(transformed.kind).toBe("document");
|
||||
if (transformed.kind !== "document") return;
|
||||
expect(noteSpans(transformed.document)[0]?.note).toBe(72);
|
||||
const edited = executeMidiTask({
|
||||
kind: "operation",
|
||||
document: transformed.document,
|
||||
operation: {
|
||||
kind: "edit-note",
|
||||
onOrder: noteSpans(transformed.document)[0]!.onOrder,
|
||||
note: 70,
|
||||
velocity: 80,
|
||||
startTick: 10,
|
||||
endTick: 240,
|
||||
},
|
||||
});
|
||||
expect(edited.kind).toBe("document");
|
||||
if (edited.kind !== "document") return;
|
||||
expect(noteSpans(edited.document)[0]).toMatchObject({
|
||||
note: 70,
|
||||
velocity: 80,
|
||||
startTick: 10,
|
||||
endTick: 240,
|
||||
});
|
||||
expect(
|
||||
executeMidiTask({
|
||||
kind: "export",
|
||||
document: edited.document,
|
||||
format: "csv",
|
||||
}).kind,
|
||||
).toBe("text");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
constantTempoMidi,
|
||||
cropMidi,
|
||||
deleteNote,
|
||||
dropChannel,
|
||||
encodeMidi,
|
||||
editNote,
|
||||
exportMidiCsv,
|
||||
exportMidiJson,
|
||||
noteSpans,
|
||||
@@ -262,4 +264,31 @@ describe("MIDI editing operations", () => {
|
||||
/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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user