Release MIDI Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 09:14:48 +02:00
parent 4b51a6d903
commit 06e095a2eb
40 changed files with 1581 additions and 126 deletions
+93
View File
@@ -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);
}
+81
View File
@@ -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");
});
});
+29
View File
@@ -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);
});
});