Blob Opera MIDI / public / app.js
public/app.js6.6 KB
import { parseMidi } from "./lib/midi.js";
import { parseMusicXml, parseMxl } from "./lib/musicxml.js";
import { assembleParts, foldToFourVoices, autoAssign, ROLES } from "./lib/arrange.js";
import { encodeRecording } from "./lib/protobuf.js";
// Google's Blob Opera recording backend. A browser can't PUT to it directly:
// it returns no Access-Control-Allow-Origin header, so the response (which
// carries the recording id we need) is unreadable cross-origin. There is no
// server in this tool — it's a static page — so the upload is bounced through
// a public CORS relay. Only the recording itself (musical notes, no personal
// data) passes through. Relays are tried in order for resilience.
const RECORDING_ENDPOINT = "https://cilex-aeiopera.uc.r.appspot.com/recording";
// Public relays come and go, so rather than try them one at a time we race
// several at once and take the first that returns a recording id. Edit this
// list if the mix stops working.
const CORS_RELAYS = [
(url) => `https://corsproxy.io/?url=${encodeURIComponent(url)}`,
(url) => `https://proxy.cors.sh/${url}`,
(url) => `https://cors.eu.org/${url}`,
];
async function uploadViaRelay(relay, bytes) {
const res = await fetch(relay(RECORDING_ENDPOINT), {
method: "PUT",
headers: { "content-type": "application/octet-stream" },
body: bytes,
});
if (!res.ok) throw new Error(`relay returned ${res.status}`);
const { id } = await res.json();
if (!id) throw new Error("no recording id in response");
return `https://gacembed.withgoogle.com/blob-opera/#/r/${id}`;
}
async function uploadRecording(bytes) {
// Promise.any resolves with the first relay to succeed, and only rejects if
// every relay fails.
try {
return await Promise.any(CORS_RELAYS.map((relay) => uploadViaRelay(relay, bytes)));
} catch {
throw new Error("every upload relay is unreachable right now — please try again in a moment");
}
}
const els = {
drop: document.getElementById("drop"),
file: document.getElementById("file"),
fold: document.getElementById("fold"),
foldToggle: document.getElementById("fold-toggle"),
tracks: document.getElementById("tracks"),
play: document.getElementById("play"),
status: document.getElementById("status"),
stage: document.getElementById("stage"),
frame: document.getElementById("frame"),
};
let parsed = null; // { tracks }
let assignments = []; // role per track
let isMidi = false; // MIDI files can be folded into 4 voices
// Fold mode applies to MIDI only, when the toggle is on.
const foldActive = () => isMidi && els.foldToggle.checked;
function setStatus(msg, kind = "") {
els.status.textContent = msg;
els.status.className = kind;
}
function noteRange(track) {
const pitches = track.notes.map((n) => n.pitch);
const name = (p) => {
const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
return names[p % 12] + (Math.floor(p / 12) - 1);
};
return `${name(Math.min(...pitches))}–${name(Math.max(...pitches))}`;
}
function renderTracks() {
els.tracks.innerHTML = "";
parsed.tracks.forEach((track, i) => {
const row = document.createElement("div");
row.className = "track";
const hasLyrics = track.events?.some((e) => e.lyric);
const info = document.createElement("div");
info.className = "track-info";
info.innerHTML = `<strong>${track.name}</strong><span>${track.notes.length} notes · ${noteRange(track)}${hasLyrics ? " · lyrics" : ""}</span>`;
const select = document.createElement("select");
for (const role of [...ROLES, "ignore"]) {
const opt = document.createElement("option");
opt.value = role;
opt.textContent = role[0].toUpperCase() + role.slice(1);
if (role === assignments[i]) opt.selected = true;
select.appendChild(opt);
}
select.addEventListener("change", () => {
assignments[i] = select.value;
});
// In fold mode the per-track role is chosen automatically, so hide it.
select.disabled = foldActive();
select.hidden = foldActive();
row.append(info, select);
els.tracks.appendChild(row);
});
els.play.disabled = false;
}
// Sniff the format from the file's leading bytes:
// "MThd" = MIDI, "PK" = zipped .mxl, "<" (after optional BOM) = MusicXML.
async function parseAny(buffer) {
const bytes = new Uint8Array(buffer);
const magic = String.fromCharCode(...bytes.subarray(0, 4));
if (magic === "MThd") return parseMidi(buffer);
if (magic.startsWith("PK")) return parseMxl(bytes);
const text = new TextDecoder().decode(bytes);
if (text.trimStart().startsWith("<")) return parseMusicXml(text);
throw new Error("unrecognized format — expected .mid, .musicxml or .mxl");
}
async function loadFile(file) {
setStatus(`Reading ${file.name}…`);
try {
const buffer = await file.arrayBuffer();
parsed = await parseAny(buffer);
if (parsed.tracks.length === 0) throw new Error("no note tracks found in this MIDI file");
isMidi = !parsed.tracks.some((t) => t.events);
els.fold.hidden = !isMidi;
assignments = autoAssign(parsed.tracks);
renderTracks();
setStatus(`Loaded ${parsed.tracks.length} track(s). Adjust the voices, then Sing.`, "ok");
} catch (err) {
parsed = null;
els.tracks.innerHTML = "";
els.play.disabled = true;
setStatus(`Could not read that file: ${err.message}`, "err");
}
}
async function sing() {
if (!parsed) return;
els.play.disabled = true;
setStatus("Arranging voices…");
try {
const parts = foldActive()
? foldToFourVoices(parsed.tracks).parts
: assembleParts(parsed.tracks, assignments);
const bytes = encodeRecording(parts);
setStatus("Uploading…");
const embedUrl = await uploadRecording(bytes);
els.stage.hidden = false;
els.frame.src = embedUrl;
setStatus("");
} catch (err) {
setStatus(`Something went wrong: ${err.message}`, "err");
} finally {
els.play.disabled = false;
}
}
// Wire up file input + drag & drop.
els.file.addEventListener("change", () => els.file.files[0] && loadFile(els.file.files[0]));
els.drop.addEventListener("click", () => els.file.click());
["dragenter", "dragover"].forEach((e) =>
els.drop.addEventListener(e, (ev) => {
ev.preventDefault();
els.drop.classList.add("hover");
})
);
["dragleave", "drop"].forEach((e) =>
els.drop.addEventListener(e, (ev) => {
ev.preventDefault();
els.drop.classList.remove("hover");
})
);
els.drop.addEventListener("drop", (ev) => {
const file = ev.dataTransfer.files[0];
if (file) loadFile(file);
});
els.foldToggle.addEventListener("change", () => {
if (parsed) renderTracks();
});
els.play.addEventListener("click", sing);
