HomeMAL' // MANUFACTORUM
datacrypt
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);