Release MIDI Tools v0.1.0
This commit is contained in:
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 0–127.",
|
||||
);
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user