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
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing MIDI Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>MIDI Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About MIDI Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Open Standard MIDI type 0 or 1 files, inspect tracks and events, edit
notes, timing, tempo or channels and export a repaired MIDI file.
</p>
<p>
All parsing and synthesis stays in this browser. Input is capped at 8
MiB, 256 tracks, 250,000 events and 1 MiB of aggregate SysEx data.
</p>
<p>
Playback uses a deliberately simple sine synthesizer, schedules at most
2,000 notes and stops by closing its AudioContext. It is a timing
preview, not a General MIDI soundfont renderer.
</p>
<p>
SMPTE time division is not supported in v0.1.0. Transpose does not
rewrite key signatures and crop retains only essential setup events.
</p>
</dialog>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { NoteSpan } from "../core/midi";
const MAX_RENDERED_NOTES = 5_000;
export function PianoRoll({
notes,
durationTicks,
}: {
notes: NoteSpan[];
durationTicks: number;
}) {
const visible = notes.slice(0, MAX_RENDERED_NOTES);
const minimum = Math.min(36, ...visible.map((note) => note.note));
const maximum = Math.max(84, ...visible.map((note) => note.note));
const pitchRange = maximum - minimum + 1;
const width = 1200;
const height = Math.max(360, pitchRange * 10);
const left = 55;
const timelineWidth = width - left;
const duration = Math.max(1, durationTicks);
return (
<div className="piano-roll-wrap" tabIndex={0}>
<svg
className="piano-roll"
viewBox={"0 0 " + width + " " + height}
role="img"
aria-label={
"Piano roll with " +
notes.length.toLocaleString() +
" notes over " +
durationTicks.toLocaleString() +
" ticks"
}
>
<title>Piano roll for the current MIDI document</title>
<rect width={width} height={height} className="roll-background" />
{Array.from({ length: pitchRange }, (_, row) => {
const note = maximum - row;
const y = (row / pitchRange) * height;
return (
<g key={note}>
<line
x1={left}
x2={width}
y1={y}
y2={y}
className={isBlack(note) ? "black-row" : ""}
/>
{note % 12 === 0 && (
<text x="4" y={y + 10}>
C{Math.floor(note / 12) - 1}
</text>
)}
</g>
);
})}
{visible.map((note) => {
const x = left + (note.startTick / duration) * timelineWidth;
const noteWidth = Math.max(
1,
((note.endTick - note.startTick) / duration) * timelineWidth,
);
const y = ((maximum - note.note) / pitchRange) * height;
return (
<rect
key={note.track + "-" + note.onOrder}
x={x}
y={y + 1}
width={noteWidth}
height={Math.max(2, height / pitchRange - 2)}
className="roll-note"
style={{ opacity: 0.45 + (note.velocity / 127) * 0.55 }}
>
<title>
{"Note " +
note.note +
", channel " +
(note.channel + 1) +
", ticks " +
note.startTick +
"" +
note.endTick}
</title>
</rect>
);
})}
</svg>
{notes.length > MAX_RENDERED_NOTES && (
<p className="muted">
Piano roll shows the first 5,000 notes; statistics and export use all
notes.
</p>
)}
</div>
);
}
function isBlack(note: number): boolean {
return [1, 3, 6, 8, 10].includes(note % 12);
}
+776
View File
@@ -0,0 +1,776 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
constantTempoMidi,
cropMidi,
dropChannel,
encodeMidi,
eventDetail,
eventLabel,
exportMidiCsv,
exportMidiJson,
MAX_MIDI_BYTES,
noteSpans,
parseMidi,
playbackNotes,
quantizeMidi,
remapChannel,
summarizeMidi,
transposeMidi,
type MidiDocument,
type MidiEvent,
} from "../core/midi";
import { PianoRoll } from "./PianoRoll";
const EXAMPLE: MidiDocument = {
name: "local-example.mid",
format: 1,
division: 480,
warnings: [],
tracks: [
{
name: "Conductor",
events: [
{
kind: "meta",
tick: 0,
order: 0,
metaType: 0x03,
data: new TextEncoder().encode("Conductor"),
},
{
kind: "meta",
tick: 0,
order: 1,
metaType: 0x51,
data: Uint8Array.of(0x07, 0xa1, 0x20),
},
{
kind: "meta",
tick: 0,
order: 2,
metaType: 0x58,
data: Uint8Array.of(4, 2, 24, 8),
},
{
kind: "meta",
tick: 0,
order: 3,
metaType: 0x59,
data: Uint8Array.of(0, 0),
},
],
},
{
name: "Triad",
events: [
{
kind: "channel",
tick: 0,
order: 4,
status: 0xc,
channel: 0,
data: Uint8Array.of(0),
},
...[60, 64, 67].flatMap((note, index): MidiEvent[] => [
{
kind: "channel",
tick: index * 480,
order: 5 + index * 2,
status: 0x9,
channel: 0,
data: Uint8Array.of(note, 92),
},
{
kind: "channel",
tick: (index + 1) * 480,
order: 6 + index * 2,
status: 0x8,
channel: 0,
data: Uint8Array.of(note, 0),
},
]),
],
},
],
};
type Playback = { context: AudioContext; timer: number };
export function Workbench() {
const [document, setDocument] = useState<MidiDocument>(EXAMPLE);
const [history, setHistory] = useState<MidiDocument[]>([]);
const [status, setStatus] = useState(
"Built-in type 1 example loaded. Choose a local .mid file to inspect.",
);
const [busy, setBusy] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [transpose, setTranspose] = useState("0");
const [grid, setGrid] = useState("120");
const [bpm, setBpm] = useState("120");
const [cropStart, setCropStart] = useState("0");
const [cropEnd, setCropEnd] = useState("1440");
const [channelFrom, setChannelFrom] = useState("1");
const [channelTo, setChannelTo] = useState("2");
const [trackFilter, setTrackFilter] = useState("all");
const [channelFilter, setChannelFilter] = useState("all");
const [eventFilter, setEventFilter] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const playbackRef = useRef<Playback | null>(null);
const summary = useMemo(() => summarizeMidi(document), [document]);
const notes = useMemo(() => noteSpans(document), [document]);
const stopPlayback = useCallback(
(message: string | null = "Playback stopped immediately.") => {
const active = playbackRef.current;
if (active) {
window.clearTimeout(active.timer);
void active.context.close();
playbackRef.current = null;
}
setIsPlaying(false);
if (message !== null) setStatus(message);
},
[],
);
useEffect(
() => () => {
const active = playbackRef.current;
if (!active) return;
window.clearTimeout(active.timer);
void active.context.close();
playbackRef.current = null;
},
[],
);
function commit(next: MidiDocument, message: string): void {
stopPlayback(null);
setHistory((current) => [...current.slice(-19), document]);
setDocument(next);
setCropEnd(String(summarizeMidi(next).durationTicks));
setStatus(message);
}
async function openFile(file: File | undefined): Promise<void> {
if (!file) return;
setBusy(true);
try {
if (file.size > MAX_MIDI_BYTES)
throw new RangeError("MIDI files are limited to 8 MiB.");
const next = parseMidi(
file.name,
new Uint8Array(await file.arrayBuffer()),
);
stopPlayback(null);
setDocument(next);
setHistory([]);
setGrid(String(Math.max(1, Math.round(next.division / 4))));
setCropStart("0");
setCropEnd(String(summarizeMidi(next).durationTicks));
setStatus(
"Parsed type " +
next.format +
" MIDI locally: " +
next.tracks.length +
" track(s).",
);
} catch (caught) {
setStatus(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
}
async function play(): Promise<void> {
stopPlayback(null);
try {
const notesToPlay = playbackNotes(document).filter(
(note) =>
note.endSeconds > note.startSeconds && note.startSeconds < 900,
);
if (notesToPlay.length === 0)
throw new Error("There are no complete notes to play.");
if (notesToPlay.length > 2_000)
throw new RangeError(
"Local synthesizer playback is capped at 2,000 notes; crop or filter the file first.",
);
const context = new AudioContext({ latencyHint: "interactive" });
await context.resume();
const master = context.createGain();
master.gain.value = 0.18;
master.connect(context.destination);
const start = context.currentTime + 0.04;
let finish = 0;
for (const note of notesToPlay) {
const oscillator = context.createOscillator();
const gain = context.createGain();
const noteStart = start + note.startSeconds;
const noteEnd =
start + Math.min(note.endSeconds, note.startSeconds + 30, 900);
oscillator.type = "sine";
oscillator.frequency.value = 440 * 2 ** ((note.note - 69) / 12);
gain.gain.setValueAtTime(0, noteStart);
gain.gain.linearRampToValueAtTime(
Math.max(0.015, note.velocity / 127),
noteStart + 0.008,
);
gain.gain.setValueAtTime(
Math.max(0.015, note.velocity / 127),
Math.max(noteStart + 0.008, noteEnd - 0.015),
);
gain.gain.linearRampToValueAtTime(0, noteEnd);
oscillator.connect(gain);
gain.connect(master);
oscillator.start(noteStart);
oscillator.stop(noteEnd + 0.02);
finish = Math.max(finish, noteEnd - start);
}
const timer = window.setTimeout(
() => stopPlayback("Playback finished."),
Math.min(901_000, (finish + 0.2) * 1000),
);
playbackRef.current = { context, timer };
setIsPlaying(true);
setStatus(
"Playing " +
notesToPlay.length.toLocaleString() +
" note(s) with the built-in sine synthesizer.",
);
} catch (caught) {
setStatus(caught instanceof Error ? caught.message : String(caught));
}
}
const visibleEvents = useMemo(
() =>
document.tracks
.flatMap((track, trackIndex) =>
track.events.map((event) => ({ trackIndex, event })),
)
.filter(
({ trackIndex, event }) =>
(trackFilter === "all" || trackIndex === Number(trackFilter)) &&
(channelFilter === "all" ||
(event.kind === "channel" &&
event.channel === Number(channelFilter))) &&
(eventFilter.trim() === "" ||
(eventLabel(event) + " " + eventDetail(event))
.toLowerCase()
.includes(eventFilter.toLowerCase())),
)
.sort(
(a, b) =>
a.event.tick - b.event.tick || a.event.order - b.event.order,
)
.slice(0, 1_000),
[channelFilter, document, eventFilter, trackFilter],
);
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Standard MIDI file workbench</p>
<h1>See, edit and hear the timeline locally.</h1>
<p>
Inspect type 0/1 events, tempo and signatures, edit musical timing
and channels, preview through Web Audio and export deterministic
MIDI, CSV or JSON.
</p>
</div>
<span className="privacy-pill">Local only</span>
</section>
<section className="panel" aria-labelledby="source-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Source</p>
<h2 id="source-title">{document.name}</h2>
</div>
<span className="count-pill">Maximum 8 MiB</span>
</div>
<div className="button-row">
<button
type="button"
className="primary-button"
disabled={busy}
onClick={() => inputRef.current?.click()}
>
{busy ? "Parsing…" : "Choose local MIDI"}
</button>
<button type="button" onClick={() => void play()}>
Play from start
</button>
<button
type="button"
disabled={!isPlaying}
onClick={() => stopPlayback()}
>
Stop now
</button>
<button
type="button"
disabled={history.length === 0}
onClick={() => {
const previous = history.at(-1);
if (!previous) return;
stopPlayback(null);
setDocument(previous);
setHistory((current) => current.slice(0, -1));
setStatus("Last edit undone.");
}}
>
Undo edit
</button>
<input
ref={inputRef}
className="sr-only"
type="file"
accept=".mid,.midi,audio/midi,audio/x-midi"
onChange={(event) => void openFile(event.target.files?.[0])}
/>
</div>
<p className="status" role="status" aria-live="polite">
{status}
</p>
{document.warnings.length > 0 && (
<ul className="warning-list">
{document.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</section>
<section className="panel" aria-labelledby="overview-title">
<div className="panel-heading">
<div>
<p className="eyebrow">
Type {document.format} · {document.division} PPQN
</p>
<h2 id="overview-title">Timeline overview</h2>
</div>
</div>
<dl className="stats-grid">
<Stat label="Tracks" value={String(summary.tracks)} />
<Stat label="Events" value={summary.events.toLocaleString()} />
<Stat label="Notes" value={summary.notes.toLocaleString()} />
<Stat
label="Channels"
value={summary.channels.join(", ") || "None"}
/>
<Stat label="Duration" value={formatTime(summary.durationSeconds)} />
<Stat label="Ticks" value={summary.durationTicks.toLocaleString()} />
</dl>
<PianoRoll notes={notes} durationTicks={summary.durationTicks} />
<div className="timeline-cards">
<Timeline
title="Tempo"
items={summary.tempos.map((item) => ({
tick: item.tick,
value: item.bpm.toFixed(3) + " BPM",
}))}
/>
<Timeline title="Time signature" items={summary.timeSignatures} />
<Timeline title="Key signature" items={summary.keySignatures} />
</div>
<PitchKeyboard notes={notes} />
</section>
<section className="operation-grid">
<Operation title="Transpose">
<label>
Semitones
<input
inputMode="numeric"
value={transpose}
onChange={(event) => setTranspose(event.target.value)}
/>
</label>
<button
type="button"
onClick={() =>
runOperation(
() => transposeMidi(document, Number(transpose)),
"Pitch events transposed.",
)
}
>
Apply transpose
</button>
</Operation>
<Operation title="Quantize notes">
<label>
Grid in ticks
<input
inputMode="numeric"
value={grid}
onChange={(event) => setGrid(event.target.value)}
/>
</label>
<button
type="button"
onClick={() =>
runOperation(
() => quantizeMidi(document, Number(grid)),
"Note boundaries quantized.",
)
}
>
Apply quantize
</button>
</Operation>
<Operation title="Constant tempo">
<label>
Beats per minute
<input
inputMode="decimal"
value={bpm}
onChange={(event) => setBpm(event.target.value)}
/>
</label>
<button
type="button"
onClick={() =>
runOperation(
() => constantTempoMidi(document, Number(bpm)),
"Tempo map replaced with one value.",
)
}
>
Set tempo
</button>
</Operation>
<Operation title="Crop timeline">
<div className="two-fields">
<label>
Start tick
<input
inputMode="numeric"
value={cropStart}
onChange={(event) => setCropStart(event.target.value)}
/>
</label>
<label>
End tick
<input
inputMode="numeric"
value={cropEnd}
onChange={(event) => setCropEnd(event.target.value)}
/>
</label>
</div>
<button
type="button"
onClick={() =>
runOperation(
() => cropMidi(document, Number(cropStart), Number(cropEnd)),
"Timeline cropped and shifted to tick zero.",
)
}
>
Crop
</button>
</Operation>
<Operation title="Channel operation">
<div className="two-fields">
<label>
From
<select
value={channelFrom}
onChange={(event) => setChannelFrom(event.target.value)}
>
{channels().map((channel) => (
<option key={channel}>{channel}</option>
))}
</select>
</label>
<label>
To
<select
value={channelTo}
onChange={(event) => setChannelTo(event.target.value)}
>
{channels().map((channel) => (
<option key={channel}>{channel}</option>
))}
</select>
</label>
</div>
<div className="button-row compact">
<button
type="button"
onClick={() =>
runOperation(
() =>
remapChannel(
document,
Number(channelFrom),
Number(channelTo),
),
"Channel remapped.",
)
}
>
Remap
</button>
<button
type="button"
onClick={() =>
runOperation(
() => dropChannel(document, Number(channelFrom)),
"Channel events removed.",
)
}
>
Remove
</button>
</div>
</Operation>
</section>
<section className="panel" aria-labelledby="events-title">
<div className="panel-heading">
<div>
<p className="eyebrow">First 1,000 matching events</p>
<h2 id="events-title">Event inspector</h2>
</div>
</div>
<div className="filter-grid">
<label>
Track
<select
value={trackFilter}
onChange={(event) => setTrackFilter(event.target.value)}
>
<option value="all">All tracks</option>
{document.tracks.map((track, index) => (
<option value={index} key={index}>
{index + 1}: {track.name}
</option>
))}
</select>
</label>
<label>
Channel
<select
value={channelFilter}
onChange={(event) => setChannelFilter(event.target.value)}
>
<option value="all">All channels</option>
{channels().map((channel) => (
<option value={channel - 1} key={channel}>
{channel}
</option>
))}
</select>
</label>
<label>
Type or detail
<input
value={eventFilter}
onChange={(event) => setEventFilter(event.target.value)}
placeholder="note, tempo, marker…"
/>
</label>
</div>
<div className="event-table" tabIndex={0}>
<table>
<thead>
<tr>
<th>Tick</th>
<th>Track</th>
<th>Channel</th>
<th>Type</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{visibleEvents.map(({ trackIndex, event }) => (
<tr key={trackIndex + "-" + event.order}>
<td>{event.tick}</td>
<td>{trackIndex + 1}</td>
<td>{event.kind === "channel" ? event.channel + 1 : "—"}</td>
<td>{eventLabel(event)}</td>
<td>
<code>{eventDetail(event)}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel" aria-labelledby="export-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Current edited document</p>
<h2 id="export-title">Export</h2>
</div>
</div>
<p className="muted">
MIDI export emits full status bytes and canonical VLQs, adds one
end-of-track event per track and preserves bounded meta/SysEx data.
CSV text is spreadsheet-safe; JSON is descriptive rather than a MIDI
interchange standard.
</p>
<div className="button-row">
<button
type="button"
onClick={() =>
download(
new Blob([new Uint8Array(encodeMidi(document))], {
type: "audio/midi",
}),
safeBase(document.name) + ".mid",
)
}
>
Download MIDI
</button>
<button
type="button"
onClick={() =>
download(
new Blob([exportMidiCsv(document)], { type: "text/csv" }),
safeBase(document.name) + ".csv",
)
}
>
Download CSV
</button>
<button
type="button"
onClick={() =>
download(
new Blob([exportMidiJson(document)], {
type: "application/json",
}),
safeBase(document.name) + ".json",
)
}
>
Download JSON
</button>
</div>
</section>
</main>
);
function runOperation(operation: () => MidiDocument, message: string): void {
try {
commit(operation(), message);
} catch (caught) {
setStatus(caught instanceof Error ? caught.message : String(caught));
}
}
}
function Operation({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="panel operation-card">
<h2>{title}</h2>
{children}
</section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function Timeline({
title,
items,
}: {
title: string;
items: Array<{ tick: number; value: string }>;
}) {
return (
<div>
<h3>{title}</h3>
{items.length === 0 ? (
<p className="muted">No explicit event</p>
) : (
<ul>
{items.slice(0, 50).map((item, index) => (
<li key={item.tick + "-" + index}>
<code>{item.tick}</code>
<span>{item.value}</span>
</li>
))}
</ul>
)}
</div>
);
}
function PitchKeyboard({ notes }: { notes: ReturnType<typeof noteSpans> }) {
const counts = Array.from({ length: 12 }, () => 0);
for (const note of notes)
counts[note.note % 12] = (counts[note.note % 12] ?? 0) + 1;
const names = [
"C",
"C♯",
"D",
"D♯",
"E",
"F",
"F♯",
"G",
"G♯",
"A",
"A♯",
"B",
];
return (
<div className="pitch-keyboard" aria-label="Pitch-class keyboard">
{names.map((name, index) => (
<div
key={name}
className={[1, 3, 6, 8, 10].includes(index) ? "black" : ""}
>
<strong>{name}</strong>
<span>{counts[index]} notes</span>
</div>
))}
</div>
);
}
function channels(): number[] {
return Array.from({ length: 16 }, (_, index) => index + 1);
}
function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
return minutes + ":" + (seconds % 60).toFixed(2).padStart(5, "0");
}
function safeBase(name: string): string {
return (
name
.replace(/\.[^.]+$/u, "")
.replace(/[^A-Za-z0-9._-]+/gu, "-")
.slice(0, 100) || "edited-midi"
);
}
function download(blob: Blob, name: string): void {
triggerBlobDownload(blob, name);
}
+968
View File
@@ -0,0 +1,968 @@
import { stableStringify, stringifyCsv } from "@add-ideas/toolbox-helpers";
export const MAX_MIDI_BYTES = 8 * 1024 * 1024;
export const MAX_TRACKS = 256;
export const MAX_EVENTS = 250_000;
export const MAX_SYSEX_BYTES = 1024 * 1024;
export const MAX_TICK = 0x7fffffff;
export type MidiEvent =
| {
kind: "channel";
tick: number;
order: number;
status: number;
channel: number;
data: Uint8Array;
}
| {
kind: "meta";
tick: number;
order: number;
metaType: number;
data: Uint8Array;
}
| {
kind: "sysex";
tick: number;
order: number;
status: 0xf0 | 0xf7;
data: Uint8Array;
};
export interface MidiTrack {
name: string;
events: MidiEvent[];
}
export interface MidiDocument {
name: string;
format: 0 | 1;
division: number;
tracks: MidiTrack[];
warnings: string[];
}
export interface NoteSpan {
track: number;
channel: number;
note: number;
velocity: number;
startTick: number;
endTick: number;
onOrder: number;
offOrder?: number;
}
export interface TempoPoint {
tick: number;
microsecondsPerQuarter: number;
bpm: number;
}
export interface MidiSummary {
tracks: number;
events: number;
notes: number;
channels: number[];
durationTicks: number;
durationSeconds: number;
tempos: TempoPoint[];
timeSignatures: Array<{ tick: number; value: string }>;
keySignatures: Array<{ tick: number; value: string }>;
}
export function parseMidi(name: string, bytes: Uint8Array): MidiDocument {
if (bytes.length > MAX_MIDI_BYTES)
throw new RangeError("MIDI files are limited to 8 MiB.");
const reader = new Reader(bytes);
if (reader.ascii(4) !== "MThd")
throw new SyntaxError("Standard MIDI header MThd is missing.");
const headerLength = reader.u32();
if (
headerLength < 6 ||
headerLength > 1024 ||
reader.remaining < headerLength
)
throw new SyntaxError("MIDI header length is invalid.");
const headerEnd = reader.offset + headerLength;
const rawFormat = reader.u16();
const trackCount = reader.u16();
const division = reader.u16();
reader.offset = headerEnd;
if (rawFormat !== 0 && rawFormat !== 1)
throw new TypeError("Only Standard MIDI file types 0 and 1 are supported.");
if (trackCount < 1 || trackCount > MAX_TRACKS)
throw new RangeError("MIDI track count must be between 1 and 256.");
if (rawFormat === 0 && trackCount !== 1)
throw new SyntaxError("MIDI type 0 must contain exactly one track.");
if ((division & 0x8000) !== 0 || division === 0)
throw new TypeError(
"SMPTE time division is not supported; PPQN is required.",
);
const tracks: MidiTrack[] = [];
const warnings = new Set<string>();
let eventCount = 0;
let sysexBytes = 0;
let order = 0;
for (let trackIndex = 0; trackIndex < trackCount; trackIndex += 1) {
if (reader.ascii(4) !== "MTrk")
throw new SyntaxError("Track " + (trackIndex + 1) + " is missing MTrk.");
const length = reader.u32();
if (length > reader.remaining)
throw new SyntaxError("Track " + (trackIndex + 1) + " is truncated.");
const end = reader.offset + length;
const events: MidiEvent[] = [];
let tick = 0;
let runningStatus: number | null = null;
let sawEnd = false;
while (reader.offset < end) {
if (++eventCount > MAX_EVENTS)
throw new RangeError("MIDI exceeds 250,000 events.");
const delta = reader.vlq(end);
tick += delta;
if (!Number.isSafeInteger(tick) || tick > MAX_TICK)
throw new RangeError("MIDI absolute tick exceeds 2,147,483,647.");
let status = reader.peek(end);
if (status < 0x80) {
if (runningStatus === null)
throw new SyntaxError(
"Running status appears without a channel status.",
);
status = runningStatus;
} else {
reader.offset += 1;
}
if (status >= 0x80 && status <= 0xef) {
runningStatus = status;
const high = status >>> 4;
const lengthForStatus = high === 0xc || high === 0xd ? 1 : 2;
const data = reader.bytes(lengthForStatus, end);
if (Array.from(data).some((value) => value > 0x7f))
throw new SyntaxError("Channel event contains a non-data byte.");
events.push({
kind: "channel",
tick,
order: order++,
status: high,
channel: status & 0x0f,
data,
});
} else if (status === 0xff) {
runningStatus = null;
const metaType = reader.byte(end);
const lengthValue = reader.vlq(end);
const data = reader.bytes(lengthValue, end);
validateMeta(metaType, data);
events.push({
kind: "meta",
tick,
order: order++,
metaType,
data,
});
if (metaType === 0x2f) {
sawEnd = true;
if (reader.offset !== end)
throw new SyntaxError("End-of-track meta event must be last.");
}
} else if (status === 0xf0 || status === 0xf7) {
runningStatus = null;
const lengthValue = reader.vlq(end);
sysexBytes += lengthValue;
if (sysexBytes > MAX_SYSEX_BYTES)
throw new RangeError("Aggregate SysEx data exceeds 1 MiB.");
events.push({
kind: "sysex",
tick,
order: order++,
status,
data: reader.bytes(lengthValue, end),
});
} else {
throw new SyntaxError(
"Unsupported system status 0x" + status.toString(16) + ".",
);
}
}
if (reader.offset !== end)
throw new SyntaxError("Track parser crossed its chunk boundary.");
if (!sawEnd)
warnings.add(
"Track " +
(trackIndex + 1) +
" has no end-of-track event; export repairs it.",
);
tracks.push({
name: trackName(events) || "Track " + (trackIndex + 1),
events,
});
}
if (reader.remaining !== 0)
throw new SyntaxError("Unexpected bytes follow the declared MIDI tracks.");
const document: MidiDocument = {
name,
format: rawFormat,
division,
tracks,
warnings: [...warnings],
};
const dangling = noteSpans(document).filter(
(note) => note.offOrder === undefined,
);
if (dangling.length > 0)
document.warnings.push(
dangling.length.toLocaleString() +
" note-on event(s) have no matching note-off.",
);
return document;
}
class Reader {
offset = 0;
private readonly source: Uint8Array;
constructor(source: Uint8Array) {
this.source = source;
}
get remaining(): number {
return this.source.length - this.offset;
}
ascii(length: number): string {
return new TextDecoder("ascii").decode(
this.bytes(length, this.source.length),
);
}
u16(): number {
const bytes = this.bytes(2, this.source.length);
return ((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0);
}
u32(): number {
const bytes = this.bytes(4, this.source.length);
return (
(bytes[0] ?? 0) * 0x1000000 +
((bytes[1] ?? 0) << 16) +
((bytes[2] ?? 0) << 8) +
(bytes[3] ?? 0)
);
}
peek(end: number): number {
if (this.offset >= end) throw new SyntaxError("MIDI event is truncated.");
return this.source[this.offset] ?? 0;
}
byte(end: number): number {
const value = this.peek(end);
this.offset += 1;
return value;
}
bytes(length: number, end: number): Uint8Array {
if (
!Number.isSafeInteger(length) ||
length < 0 ||
this.offset + length > end
)
throw new SyntaxError("MIDI field is truncated or has invalid length.");
const value = this.source.slice(this.offset, this.offset + length);
this.offset += length;
return value;
}
vlq(end: number): number {
let value = 0;
for (let count = 0; count < 4; count += 1) {
const byte = this.byte(end);
value = value * 128 + (byte & 0x7f);
if ((byte & 0x80) === 0) return value;
}
throw new SyntaxError("MIDI VLQ exceeds four bytes.");
}
}
function validateMeta(type: number, data: Uint8Array): void {
const exact: Record<number, number> = {
0x00: 2,
0x20: 1,
0x21: 1,
0x2f: 0,
0x51: 3,
0x54: 5,
0x58: 4,
0x59: 2,
};
if (exact[type] !== undefined && data.length !== exact[type])
throw new SyntaxError(
"Meta event 0x" + type.toString(16) + " has invalid length.",
);
if (type === 0x51 && tempoValue(data) === 0)
throw new SyntaxError("Tempo meta event cannot contain zero.");
if (type === 0x58 && (data[1] ?? 0) > 7)
throw new SyntaxError("Time-signature denominator exponent is too large.");
if (
type === 0x59 &&
(((data[0] ?? 0) << 24) >> 24 < -7 ||
((data[0] ?? 0) << 24) >> 24 > 7 ||
(data[1] !== 0 && data[1] !== 1))
)
throw new SyntaxError("Key-signature meta event is invalid.");
}
function trackName(events: MidiEvent[]): string {
const event = events.find(
(candidate) => candidate.kind === "meta" && candidate.metaType === 0x03,
);
return event && event.kind === "meta"
? textValue(event.data).slice(0, 200)
: "";
}
function textValue(data: Uint8Array): string {
return new TextDecoder("utf-8", { fatal: false }).decode(data);
}
function tempoValue(data: Uint8Array): number {
return (data[0] ?? 0) * 0x10000 + (data[1] ?? 0) * 0x100 + (data[2] ?? 0);
}
export function noteSpans(document: MidiDocument): NoteSpan[] {
const pending = new Map<string, NoteSpan[]>();
const notes: NoteSpan[] = [];
const maximum = maxTick(document);
for (const [trackIndex, track] of document.tracks.entries()) {
const events = [...track.events].sort(eventOrder);
for (const event of events) {
if (event.kind !== "channel") continue;
const note = event.data[0] ?? 0;
const velocity = event.data[1] ?? 0;
const key = trackIndex + ":" + event.channel + ":" + note;
if (event.status === 0x9 && velocity > 0) {
const span: NoteSpan = {
track: trackIndex,
channel: event.channel,
note,
velocity,
startTick: event.tick,
endTick: maximum,
onOrder: event.order,
};
notes.push(span);
const queue = pending.get(key) ?? [];
queue.push(span);
pending.set(key, queue);
} else if (
event.status === 0x8 ||
(event.status === 0x9 && velocity === 0)
) {
const queue = pending.get(key);
const span = queue?.shift();
if (span) {
span.endTick = Math.max(span.startTick, event.tick);
span.offOrder = event.order;
}
}
}
}
return notes.sort(
(a, b) =>
a.startTick - b.startTick ||
a.track - b.track ||
a.channel - b.channel ||
a.note - b.note ||
a.onOrder - b.onOrder,
);
}
function eventOrder(a: MidiEvent, b: MidiEvent): number {
return a.tick - b.tick || a.order - b.order;
}
export function maxTick(document: MidiDocument): number {
return Math.max(
0,
...document.tracks.flatMap((track) =>
track.events.map((event) => event.tick),
),
);
}
export function tempoMap(document: MidiDocument): TempoPoint[] {
const events = document.tracks
.flatMap((track) => track.events)
.filter(
(event): event is Extract<MidiEvent, { kind: "meta" }> =>
event.kind === "meta" && event.metaType === 0x51,
)
.sort(eventOrder);
const points: TempoPoint[] = [
{ tick: 0, microsecondsPerQuarter: 500_000, bpm: 120 },
];
for (const event of events) {
const microseconds = tempoValue(event.data);
const point = {
tick: event.tick,
microsecondsPerQuarter: microseconds,
bpm: 60_000_000 / microseconds,
};
if (points.at(-1)?.tick === event.tick) points[points.length - 1] = point;
else points.push(point);
}
return points;
}
export function tickToSeconds(
document: MidiDocument,
targetTick: number,
): number {
const tempos = tempoMap(document);
let seconds = 0;
let previousTick = 0;
let tempo = tempos[0]?.microsecondsPerQuarter ?? 500_000;
for (const point of tempos.slice(1)) {
if (point.tick >= targetTick) break;
seconds +=
((point.tick - previousTick) * tempo) / document.division / 1_000_000;
previousTick = point.tick;
tempo = point.microsecondsPerQuarter;
}
return (
seconds +
((Math.max(targetTick, previousTick) - previousTick) * tempo) /
document.division /
1_000_000
);
}
export function summarizeMidi(document: MidiDocument): MidiSummary {
const notes = noteSpans(document);
const durationTicks = maxTick(document);
const channels = new Set<number>();
const timeSignatures: Array<{ tick: number; value: string }> = [];
const keySignatures: Array<{ tick: number; value: string }> = [];
for (const track of document.tracks)
for (const event of track.events) {
if (event.kind === "channel") channels.add(event.channel + 1);
if (event.kind === "meta" && event.metaType === 0x58)
timeSignatures.push({
tick: event.tick,
value:
String(event.data[0] ?? 0) +
"/" +
String(2 ** (event.data[1] ?? 0)),
});
if (event.kind === "meta" && event.metaType === 0x59)
keySignatures.push({
tick: event.tick,
value: keyName(event.data[0] ?? 0, event.data[1] ?? 0),
});
}
return {
tracks: document.tracks.length,
events: document.tracks.reduce(
(sum, track) => sum + track.events.length,
0,
),
notes: notes.length,
channels: [...channels].sort((a, b) => a - b),
durationTicks,
durationSeconds: tickToSeconds(document, durationTicks),
tempos: tempoMap(document),
timeSignatures: timeSignatures.sort((a, b) => a.tick - b.tick),
keySignatures: keySignatures.sort((a, b) => a.tick - b.tick),
};
}
function keyName(raw: number, mode: number): string {
const signed = (raw << 24) >> 24;
const major = [
"Cb",
"Gb",
"Db",
"Ab",
"Eb",
"Bb",
"F",
"C",
"G",
"D",
"A",
"E",
"B",
"F#",
"C#",
];
const minor = [
"Abm",
"Ebm",
"Bbm",
"Fm",
"Cm",
"Gm",
"Dm",
"Am",
"Em",
"Bm",
"F#m",
"C#m",
"G#m",
"D#m",
"A#m",
];
return (mode === 1 ? minor : major)[signed + 7] ?? "Unknown";
}
export function cloneMidi(document: MidiDocument): MidiDocument {
return {
...document,
tracks: document.tracks.map((track) => ({
...track,
events: track.events.map((event) => ({
...event,
data: new Uint8Array(event.data),
})) as MidiEvent[],
})),
warnings: [...document.warnings],
};
}
export function transposeMidi(
document: MidiDocument,
semitones: number,
): MidiDocument {
if (!Number.isInteger(semitones) || semitones < -127 || semitones > 127)
throw new RangeError("Transpose must be an integer from -127 to 127.");
const result = cloneMidi(document);
for (const track of result.tracks)
for (const event of track.events)
if (event.kind === "channel" && [0x8, 0x9, 0xa].includes(event.status)) {
const next = (event.data[0] ?? 0) + semitones;
if (next < 0 || next > 127)
throw new RangeError(
"Transpose would move at least one note outside MIDI 0127.",
);
event.data[0] = next;
}
if (semitones !== 0)
result.warnings = unique([
...result.warnings,
"Pitch events were transposed; key-signature metadata was not rewritten.",
]);
return result;
}
export function quantizeMidi(
document: MidiDocument,
grid: number,
): MidiDocument {
if (!Number.isSafeInteger(grid) || grid < 1 || grid > MAX_TICK)
throw new RangeError("Quantize grid must be a positive whole tick value.");
const result = cloneMidi(document);
const byOrder = new Map<number, MidiEvent>();
for (const track of result.tracks)
for (const event of track.events) byOrder.set(event.order, event);
const paired = new Set<number>();
for (const note of noteSpans(result)) {
const on = byOrder.get(note.onOrder);
const off =
note.offOrder === undefined ? undefined : byOrder.get(note.offOrder);
if (!on) continue;
const start = quantized(on.tick, grid);
on.tick = start;
paired.add(on.order);
if (off) {
off.tick = Math.max(start + grid, quantized(off.tick, grid));
if (off.tick > MAX_TICK)
throw new RangeError("Quantized note exceeds the maximum tick.");
paired.add(off.order);
}
}
for (const track of result.tracks)
for (const event of track.events)
if (
event.kind === "channel" &&
[0x8, 0x9, 0xa].includes(event.status) &&
!paired.has(event.order)
)
event.tick = quantized(event.tick, grid);
sortTracks(result);
return result;
}
function quantized(tick: number, grid: number): number {
const value = Math.round(tick / grid) * grid;
if (value > MAX_TICK) throw new RangeError("Quantized tick is too large.");
return value;
}
export function constantTempoMidi(
document: MidiDocument,
bpm: number,
): MidiDocument {
if (!Number.isFinite(bpm) || bpm < 4 || bpm > 1000)
throw new RangeError("Tempo must be between 4 and 1,000 BPM.");
const result = cloneMidi(document);
for (const track of result.tracks)
track.events = track.events.filter(
(event) => !(event.kind === "meta" && event.metaType === 0x51),
);
const microseconds = Math.round(60_000_000 / bpm);
result.tracks[0]!.events.push({
kind: "meta",
tick: 0,
order: minimumOrder(result) - 1,
metaType: 0x51,
data: Uint8Array.of(
(microseconds >>> 16) & 0xff,
(microseconds >>> 8) & 0xff,
microseconds & 0xff,
),
});
sortTracks(result);
return result;
}
export function remapChannel(
document: MidiDocument,
from: number,
to: number,
): MidiDocument {
if (
!Number.isInteger(from) ||
!Number.isInteger(to) ||
from < 1 ||
from > 16 ||
to < 1 ||
to > 16
)
throw new RangeError("MIDI channels must be from 1 to 16.");
const result = cloneMidi(document);
for (const track of result.tracks)
for (const event of track.events)
if (event.kind === "channel" && event.channel === from - 1)
event.channel = to - 1;
return result;
}
export function dropChannel(
document: MidiDocument,
channel: number,
): MidiDocument {
if (!Number.isInteger(channel) || channel < 1 || channel > 16)
throw new RangeError("MIDI channel must be from 1 to 16.");
const result = cloneMidi(document);
for (const track of result.tracks)
track.events = track.events.filter(
(event) => event.kind !== "channel" || event.channel !== channel - 1,
);
return result;
}
export function cropMidi(
document: MidiDocument,
start: number,
end: number,
): MidiDocument {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end <= start ||
end > MAX_TICK
)
throw new RangeError(
"Crop range must be whole ticks with end after start.",
);
const result = cloneMidi(document);
const spans = noteSpans(result);
const pairedOrders = new Set(
spans.flatMap((span) => [
span.onOrder,
...(span.offOrder === undefined ? [] : [span.offOrder]),
]),
);
const byOrder = new Map<number, MidiEvent>();
for (const track of result.tracks)
for (const event of track.events) byOrder.set(event.order, event);
const keepNoteOrders = new Set<number>();
for (const span of spans) {
if (span.endTick < start || span.startTick > end) continue;
const on = byOrder.get(span.onOrder);
const off =
span.offOrder === undefined ? undefined : byOrder.get(span.offOrder);
if (!on) continue;
on.tick = Math.max(span.startTick, start) - start;
keepNoteOrders.add(on.order);
if (off) {
off.tick = Math.min(span.endTick, end) - start;
if (off.tick < on.tick) off.tick = on.tick;
keepNoteOrders.add(off.order);
}
}
for (const track of result.tracks) {
const setup = latestSetupEvents(track.events, start);
track.events = [
...setup,
...track.events
.filter((event) => {
if (pairedOrders.has(event.order))
return keepNoteOrders.has(event.order);
return event.tick >= start && event.tick <= end;
})
.map((event) =>
pairedOrders.has(event.order)
? event
: ({ ...event, tick: event.tick - start } as MidiEvent),
),
];
}
sortTracks(result);
return result;
}
function latestSetupEvents(events: MidiEvent[], start: number): MidiEvent[] {
const selected = new Map<string, MidiEvent>();
for (const event of [...events].sort(eventOrder)) {
if (event.tick >= start) break;
if (event.kind === "meta" && [0x51, 0x58, 0x59].includes(event.metaType))
selected.set("m" + event.metaType, event);
if (event.kind === "channel" && [0xb, 0xc].includes(event.status))
selected.set(
"c" +
event.channel +
":" +
event.status +
":" +
(event.status === 0xb ? event.data[0] : 0),
event,
);
}
return [...selected.values()].map(
(event) =>
({
...event,
tick: 0,
order: event.order - MAX_EVENTS,
data: new Uint8Array(event.data),
}) as MidiEvent,
);
}
function minimumOrder(document: MidiDocument): number {
return Math.min(
0,
...document.tracks.flatMap((track) =>
track.events.map((event) => event.order),
),
);
}
function sortTracks(document: MidiDocument): void {
for (const track of document.tracks) track.events.sort(eventOrder);
}
function unique(values: string[]): string[] {
return [...new Set(values)];
}
export function encodeMidi(document: MidiDocument): Uint8Array {
if (
document.tracks.length < 1 ||
document.tracks.length > MAX_TRACKS ||
(document.format === 0 && document.tracks.length !== 1)
)
throw new TypeError("Document track count does not match MIDI format.");
const trackChunks = document.tracks.map((track) => encodeTrack(track));
const total =
14 + trackChunks.reduce((sum, chunk) => sum + 8 + chunk.length, 0);
if (total > MAX_MIDI_BYTES)
throw new RangeError("Exported MIDI would exceed 8 MiB.");
const output = new Uint8Array(total);
const view = new DataView(output.buffer);
output.set(new TextEncoder().encode("MThd"), 0);
view.setUint32(4, 6);
view.setUint16(8, document.format);
view.setUint16(10, document.tracks.length);
view.setUint16(12, document.division);
let offset = 14;
for (const chunk of trackChunks) {
output.set(new TextEncoder().encode("MTrk"), offset);
view.setUint32(offset + 4, chunk.length);
output.set(chunk, offset + 8);
offset += 8 + chunk.length;
}
return output;
}
function encodeTrack(track: MidiTrack): Uint8Array {
const output: number[] = [];
const events = track.events
.filter((event) => !(event.kind === "meta" && event.metaType === 0x2f))
.sort(eventOrder);
let tick = 0;
for (const event of events) {
if (event.tick < tick || event.tick > MAX_TICK)
throw new RangeError("Track event order/tick is invalid.");
pushDelta(output, event.tick - tick);
tick = event.tick;
if (event.kind === "channel") {
output.push((event.status << 4) | event.channel, ...event.data);
} else if (event.kind === "meta") {
output.push(
0xff,
event.metaType,
...encodeVlq(event.data.length),
...event.data,
);
} else {
output.push(event.status, ...encodeVlq(event.data.length), ...event.data);
}
if (output.length > MAX_MIDI_BYTES)
throw new RangeError("Track encoding exceeds 8 MiB.");
}
output.push(0, 0xff, 0x2f, 0);
return Uint8Array.from(output);
}
/** Split a long silent gap with inert sequencer-specific meta events. */
function pushDelta(output: number[], value: number): void {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError("MIDI delta must be a non-negative whole number.");
let remaining = value;
while (remaining > 0x0fffffff) {
output.push(...encodeVlq(0x0fffffff), 0xff, 0x7f, 0);
remaining -= 0x0fffffff;
}
output.push(...encodeVlq(remaining));
}
function encodeVlq(value: number): number[] {
if (!Number.isSafeInteger(value) || value < 0 || value > 0x0fffffff)
throw new RangeError("MIDI delta exceeds the four-byte VLQ range.");
const bytes = [value & 0x7f];
for (let remaining = value >>> 7; remaining > 0; remaining >>>= 7)
bytes.unshift((remaining & 0x7f) | 0x80);
return bytes;
}
export function eventLabel(event: MidiEvent): string {
if (event.kind === "sysex")
return event.status === 0xf0 ? "SysEx" : "SysEx continuation";
if (event.kind === "meta") {
const names: Record<number, string> = {
0x01: "Text",
0x02: "Copyright",
0x03: "Track name",
0x04: "Instrument",
0x05: "Lyric",
0x06: "Marker",
0x07: "Cue",
0x2f: "End of track",
0x51: "Tempo",
0x58: "Time signature",
0x59: "Key signature",
};
return names[event.metaType] ?? "Meta 0x" + event.metaType.toString(16);
}
return (
{
0x8: "Note off",
0x9: (event.data[1] ?? 0) === 0 ? "Note off" : "Note on",
0xa: "Poly pressure",
0xb: "Control change",
0xc: "Program change",
0xd: "Channel pressure",
0xe: "Pitch bend",
}[event.status] ?? "Channel event"
);
}
export function eventDetail(event: MidiEvent): string {
if (event.kind === "meta") {
if (event.metaType === 0x51)
return (60_000_000 / tempoValue(event.data)).toFixed(3) + " BPM";
if (event.metaType === 0x58)
return (event.data[0] ?? 0) + "/" + 2 ** (event.data[1] ?? 0);
if (event.metaType === 0x59)
return keyName(event.data[0] ?? 0, event.data[1] ?? 0);
if ([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07].includes(event.metaType))
return Array.from(textValue(event.data), (character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127 ? "" : character;
}).join("");
}
return Array.from(event.data, (value) =>
value.toString(16).padStart(2, "0"),
).join(" ");
}
export function exportMidiJson(document: MidiDocument): string {
return (
stableStringify(
{
format: document.format,
division: document.division,
tracks: document.tracks.map((track, trackIndex) => ({
index: trackIndex + 1,
name: track.name,
events: track.events.map((event) => ({
tick: event.tick,
kind: event.kind,
type: eventLabel(event),
...(event.kind === "channel" ? { channel: event.channel + 1 } : {}),
detail: eventDetail(event),
dataHex: Array.from(event.data, (value) =>
value.toString(16).padStart(2, "0"),
).join(""),
})),
})),
},
2,
{ maxTextChars: 16_000_000, maxDepth: 16, maxNodes: 1_000_000 },
) + "\n"
);
}
export function exportMidiCsv(document: MidiDocument): string {
const rows: Array<Array<string | number>> = [
["track", "tick", "kind", "type", "channel", "detail", "data_hex"],
];
document.tracks.forEach((track, trackIndex) =>
track.events.forEach((event) =>
rows.push([
trackIndex + 1,
event.tick,
event.kind,
eventLabel(event),
event.kind === "channel" ? event.channel + 1 : "",
safeCsv(eventDetail(event)),
Array.from(event.data, (value) =>
value.toString(16).padStart(2, "0"),
).join(""),
]),
),
);
return stringifyCsv(rows) + "\r\n";
}
function safeCsv(value: string): string {
return /^[=+\-@]/u.test(value) ? "'" + value : value;
}
export function playbackNotes(document: MidiDocument): Array<
NoteSpan & {
startSeconds: number;
endSeconds: number;
}
> {
return noteSpans(document).map((note) => ({
...note,
startSeconds: tickToSeconds(document, note.startTick),
endSeconds: tickToSeconds(document, note.endTick),
}));
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+502
View File
@@ -0,0 +1,502 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #fff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #202332;
--toolbox-muted: #656b7d;
--toolbox-border: #d9dce7;
--toolbox-accent: #6544c7;
--toolbox-accent-hover: #5032ad;
--toolbox-accent-soft: #eee9ff;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #087b72;
--toolbox-danger: #b42342;
--midi-warning: #9a5d06;
--midi-radius: 0.82rem;
--midi-shadow: 0 1px 2px rgb(24 31 65 / 4%), 0 10px 30px rgb(24 31 65 / 3%);
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
text-rendering: optimizeLegibility;
}
#root {
min-height: 100vh;
}
::selection {
background: color-mix(in srgb, var(--toolbox-focus) 38%, transparent);
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
color: var(--toolbox-text);
}
.workbench :where(h1, h2, h3),
.help-dialog :where(h2, h3),
.fatal h1 {
margin: 0;
color: var(--toolbox-text);
font-weight: 760;
letter-spacing: -0.027em;
line-height: 1.16;
}
.workbench h1 {
font-size: clamp(1.75rem, 3vw, 2.55rem);
}
.workbench h2,
.help-dialog h2 {
font-size: clamp(1.18rem, 2vw, 1.5rem);
}
.workbench h3 {
font-size: 0.96rem;
}
.workbench :where(p, ul, ol, dl) {
margin-block: 0;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.workbench :where(button, input, select),
.help-dialog button {
font: inherit;
}
.workbench button,
.help-dialog button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.64rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-size: 0.82rem;
font-weight: 720;
line-height: 1.2;
cursor: pointer;
touch-action: manipulation;
}
.workbench button:hover:not(:disabled) {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 58%,
var(--toolbox-border)
);
background: var(--toolbox-surface-soft);
}
.workbench button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.workbench .primary-button {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.workbench .primary-button:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
:where(.workbench, .help-dialog)
:where(button, input, select, summary):focus-visible,
.event-table:focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
.workbench :where(input, select) {
width: 100%;
min-width: 0;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.workbench label {
min-width: 0;
display: grid;
gap: 0.34rem;
color: var(--toolbox-muted);
font-size: 0.77rem;
font-weight: 720;
}
.hero,
.panel {
min-width: 0;
border: 1px solid var(--toolbox-border);
border-radius: var(--midi-radius);
background: var(--toolbox-surface);
box-shadow: var(--midi-shadow);
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero p:not(.eyebrow) {
max-width: 58rem;
margin-top: 0.55rem;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem !important;
color: var(--toolbox-accent);
font-size: 0.68rem;
font-weight: 820;
letter-spacing: 0.115em;
line-height: 1.35;
text-transform: uppercase;
}
.privacy-pill,
.count-pill {
flex: 0 0 auto;
padding: 0.38rem 0.64rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 780;
}
.panel {
padding: clamp(0.9rem, 2vw, 1.2rem);
}
.panel-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
}
.button-row.compact {
margin-top: 0;
}
.status,
.muted {
color: var(--toolbox-muted);
font-size: 0.82rem;
line-height: 1.55;
}
.status {
min-height: 1.3rem;
margin-top: 0.7rem !important;
}
.warning-list {
display: grid;
gap: 0.35rem;
margin-top: 0.65rem !important;
padding: 0;
color: var(--midi-warning);
font-size: 0.79rem;
list-style: none;
}
.warning-list li {
padding: 0.48rem 0.62rem;
border-left: 0.2rem solid currentColor;
border-radius: 0.35rem;
background: color-mix(
in srgb,
var(--midi-warning) 9%,
var(--toolbox-surface)
);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.55rem;
margin-bottom: 0.9rem !important;
}
.stats-grid div {
min-width: 0;
padding: 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.6rem;
background: var(--toolbox-surface-soft);
}
.stats-grid dt {
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 780;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.stats-grid dd {
margin: 0.25rem 0 0;
overflow: hidden;
font-size: 0.88rem;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.piano-roll-wrap {
max-width: 100%;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
scrollbar-gutter: stable;
}
.piano-roll {
display: block;
width: 100%;
min-width: 44rem;
height: auto;
min-height: 18rem;
}
.piano-roll .roll-background {
fill: var(--toolbox-surface);
}
.piano-roll line {
stroke: var(--toolbox-border);
stroke-width: 0.7;
}
.piano-roll .black-row {
stroke: color-mix(in srgb, var(--toolbox-text) 18%, var(--toolbox-border));
stroke-width: 7;
}
.piano-roll .roll-note {
fill: var(--toolbox-accent);
stroke: color-mix(in srgb, var(--toolbox-accent) 70%, #000);
stroke-width: 0.6;
}
.piano-roll text {
fill: var(--toolbox-muted);
font-size: 10px;
}
.timeline-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.85rem;
}
.timeline-cards > div {
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
}
.timeline-cards ul {
max-height: 8.5rem;
display: grid;
gap: 0.3rem;
overflow: auto;
margin-top: 0.5rem !important;
padding: 0;
list-style: none;
}
.timeline-cards li {
display: flex;
justify-content: space-between;
gap: 0.65rem;
font-size: 0.75rem;
}
.timeline-cards code,
.event-table code {
color: var(--toolbox-muted);
}
.pitch-keyboard {
display: grid;
grid-template-columns: repeat(12, minmax(3.3rem, 1fr));
overflow-x: auto;
margin-top: 0.85rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
}
.pitch-keyboard > div {
min-width: 3.3rem;
min-height: 4.4rem;
display: grid;
align-content: space-between;
padding: 0.48rem;
border-right: 1px solid var(--toolbox-border);
background: var(--toolbox-surface);
}
.pitch-keyboard > div:last-child {
border-right: 0;
}
.pitch-keyboard > div.black {
background: var(--toolbox-text);
color: var(--toolbox-surface);
}
.pitch-keyboard span {
font-size: 0.65rem;
opacity: 0.72;
}
.operation-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1rem;
}
.operation-card {
display: grid;
align-content: start;
gap: 0.7rem;
}
.operation-card h2 {
font-size: 1rem;
}
.operation-card > button {
align-self: end;
}
.two-fields,
.filter-grid {
display: grid;
gap: 0.55rem;
}
.two-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-grid {
grid-template-columns: minmax(10rem, 0.7fr) minmax(10rem, 0.7fr) minmax(
14rem,
1.6fr
);
margin-bottom: 0.75rem;
}
.event-table {
max-height: 32rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
scrollbar-gutter: stable;
}
.event-table table {
width: 100%;
min-width: 42rem;
border-collapse: collapse;
font-size: 0.77rem;
}
.event-table :where(th, td) {
padding: 0.5rem 0.62rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
.event-table th {
position: sticky;
z-index: 1;
top: 0;
background: var(--toolbox-surface-soft);
}
.event-table tr:hover td {
background: var(--toolbox-surface-soft);
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(38rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
.help-dialog p,
.help-dialog li {
line-height: 1.55;
}
@media (max-width: 72rem) {
.operation-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 52rem) {
.operation-grid,
.timeline-cards {
grid-template-columns: 1fr;
}
.filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-grid label:last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 38rem) {
.hero,
.panel-heading {
flex-direction: column;
}
.privacy-pill {
order: -1;
}
.operation-grid,
.filter-grid,
.stats-grid {
grid-template-columns: 1fr;
}
.filter-grid label:last-child {
grid-column: auto;
}
.panel-heading {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.midi-tools",
"name": "MIDI Tools",
"version": "0.1.0",
"description": "Inspect, edit and play MIDI locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["audio", "media", "developer"],
"tags": ["midi", "piano roll", "tempo", "sequencer", "web audio"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/midi-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/midi-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />