Blob Opera MIDI / test / pipeline.test.js
test/pipeline.test.js9.5 KB
// End-to-end check: synthesize a MIDI, run it through the real browser modules
// (parse -> arrange -> encode), then upload to Google to prove the bytes are
// accepted. Run with `npm test`. Pass --no-network to skip the upload.
import assert from "node:assert";
import https from "node:https";
import { parseMidi } from "../public/lib/midi.js";
import { parseMusicXml } from "../public/lib/musicxml.js";
import { textToPhonemes, PHONEMES } from "../public/lib/lyrics.js";
import { assembleParts, foldToFourVoices, autoAssign } from "../public/lib/arrange.js";
import { encodeRecording } from "../public/lib/protobuf.js";
// --- tiny SMF writer, just enough to produce a test file --------------------
function vlq(n) {
const bytes = [n & 0x7f];
n >>= 7;
while (n > 0) {
bytes.unshift((n & 0x7f) | 0x80);
n >>= 7;
}
return bytes;
}
function trackChunk(events, name) {
let body = [...vlq(0), 0xff, 0x03, name.length, ...[...name].map((c) => c.charCodeAt(0))];
for (const ev of events) body.push(...vlq(ev.delta), ...ev.data);
body.push(...vlq(0), 0xff, 0x2f, 0x00); // end of track
const len = body.length;
return [0x4d, 0x54, 0x72, 0x6b, (len >> 24) & 0xff, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff, ...body];
}
// A melodic line: notes = [{pitch, startTicks, durTicks}], quarter = 480 ticks.
function melodyTrack(notes, name) {
const events = [];
const points = [];
for (const n of notes) {
points.push({ tick: n.startTicks, data: [0x90, n.pitch, 0x64] });
points.push({ tick: n.startTicks + n.durTicks, data: [0x80, n.pitch, 0x00] });
}
points.sort((a, b) => a.tick - b.tick);
let last = 0;
for (const p of points) {
events.push({ delta: p.tick - last, data: p.data });
last = p.tick;
}
return trackChunk(events, name);
}
function buildMidi() {
const header = [0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 1, 0, 2, 0x01, 0xe0]; // format 1, 2 tracks, 480 tpq
const q = 480;
// Soprano-ish (high) and bass-ish (low) lines, with a rest in the middle.
const high = melodyTrack(
[
{ pitch: 72, startTicks: 0, durTicks: q },
{ pitch: 74, startTicks: q, durTicks: q },
{ pitch: 76, startTicks: 3 * q, durTicks: q }, // gap at 2q => rest
],
"High"
);
const low = melodyTrack(
[
{ pitch: 48, startTicks: 0, durTicks: 2 * q },
{ pitch: 50, startTicks: 2 * q, durTicks: 2 * q },
],
"Low"
);
return new Uint8Array([...header, ...high, ...low]).buffer;
}
// --- upload helper (server-to-server, no CORS) ------------------------------
function upload(bytes) {
return new Promise((resolve, reject) => {
const req = https.request(
{ host: "cilex-aeiopera.uc.r.appspot.com", path: "/recording", method: "PUT" },
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
if (res.statusCode >= 300) return reject(new Error(`HTTP ${res.statusCode}: ${Buffer.concat(chunks)}`));
resolve(JSON.parse(Buffer.concat(chunks).toString()).id);
});
}
);
req.on("error", reject);
req.end(Buffer.from(bytes));
});
}
// --- run --------------------------------------------------------------------
const midi = buildMidi();
const { tracks } = parseMidi(midi);
assert.equal(tracks.length, 2, "should find 2 note tracks");
assert.equal(tracks[0].notes.length, 3, "high track should have 3 notes");
const assignments = autoAssign(tracks);
assert.equal(assignments[0], "soprano", "highest track auto-assigned to soprano");
assert.equal(assignments[1], "alto", "next track to alto");
const parts = assembleParts(tracks, assignments);
assert.equal(parts.length, 4, "must produce exactly 4 voices");
for (const p of parts) assert.ok(p.notes.length > 0, "every voice must be non-empty (fallback fill)");
// The soprano's rest (2q..3q) should appear as a silence phoneme.
const sopHasSilence = parts[0].notes.some((n) => n.vowel.phoneme === PHONEMES.sil);
assert.ok(sopHasSilence, "soprano should contain a rest/silence");
const bytes = encodeRecording(parts);
assert.ok(bytes.length > 0, "encoded recording should be non-empty");
console.log(`✓ MIDI: parsed, arranged, encoded ${bytes.length} bytes`);
// --- MusicXML path -----------------------------------------------------------
assert.deepEqual(textToPhonemes("Glo-ri-a!"), [PHONEMES.g, PHONEMES.l, PHONEMES.o, PHONEMES.r, PHONEMES.i, PHONEMES.a]);
assert.deepEqual(textToPhonemes("che"), [PHONEMES.ch, PHONEMES.e], "digraphs match longest-first");
const musicXml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<part-list>
<score-part id="P1"><part-name>Voice</part-name></score-part>
<score-part id="P2"><part-name>Bass</part-name></score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>2</divisions></attributes>
<direction><sound tempo="120"/></direction>
<note><pitch><step>C</step><octave>5</octave></pitch><duration>2</duration>
<lyric><syllabic>begin</syllabic><text>la</text></lyric></note>
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration>
<lyric><syllabic>end</syllabic><text>ve</text></lyric></note>
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><tie type="start"/></note>
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><tie type="stop"/></note>
</measure>
</part>
<part id="P2">
<measure number="1">
<attributes><divisions>2</divisions></attributes>
<note><pitch><step>C</step><octave>3</octave></pitch><duration>4</duration></note>
<note><chord/><pitch><step>E</step><octave>3</octave></pitch><duration>4</duration></note>
<note><pitch><step>G</step><octave>3</octave></pitch><duration>4</duration></note>
</measure>
</part>
</score-partwise>`;
const xml = parseMusicXml(musicXml);
assert.equal(xml.tracks.length, 2, "should find 2 MusicXML parts");
assert.equal(xml.tracks[0].name, "Voice");
assert.equal(xml.tracks[0].events.length, 3, "tied notes merge into one event");
assert.equal(xml.tracks[0].events[2].duration, 1, "merged tie lasts two quarters (1s at 120bpm)");
assert.equal(xml.tracks[0].events[0].lyric, "la");
const xmlAssignments = autoAssign(xml.tracks);
const xmlParts = assembleParts(xml.tracks, xmlAssignments);
const soprano = xmlParts[0];
// Leading consonant of the first syllable ("l" of "la") must move to startSuffix.
assert.equal(soprano.startSuffix.length, 1, "startSuffix carries the leading consonant");
assert.equal(soprano.startSuffix[0].phoneme, PHONEMES.l);
assert.equal(soprano.notes[0].vowel.phoneme, PHONEMES.a, "first note sings the vowel of 'la'");
// "ve": the "v" should land on the previous note's suffix.
assert.ok(soprano.notes[0].suffix.some((s) => s.phoneme === PHONEMES.v), "'v' migrates to previous note's suffix");
// Tied note has no lyric → melisma: keeps singing the last vowel ("e").
const tied = soprano.notes.find((n) => n.time === 1);
assert.equal(tied.vowel.phoneme, PHONEMES.e, "melisma reuses the last vowel");
// Chord members collapse to the highest pitch (C3+E3 chord → E3).
assert.equal(xml.tracks[1].events[0].pitch, 52, "chord keeps highest pitch");
const xmlBytes = encodeRecording(xmlParts);
assert.ok(xmlBytes.length > 0);
console.log(`✓ MusicXML: parsed, arranged, encoded ${xmlBytes.length} bytes`);
// --- MIDI 4-voice fold (keep every note) -------------------------------------
const trk = (...notes) => ({ name: "t", notes });
const n = (start, end, pitch) => ({ start, end, pitch });
// Four simultaneous notes across two tracks + a later fifth note: nothing lost.
const chord4 = foldToFourVoices([
trk(n(0, 1, 60), n(0, 1, 64)),
trk(n(0, 1, 67), n(0, 1, 72), n(1, 2, 69)),
]);
assert.equal(chord4.total, 5, "all pooled notes counted");
assert.equal(chord4.dropped, 0, "four-at-once keeps every note");
assert.equal(chord4.parts.length, 4, "always four parts");
// Every voice must be monophonic: sung note onsets strictly increasing.
for (const part of chord4.parts) {
for (let i = 1; i < part.notes.length; i++) {
assert.ok(part.notes[i].time > part.notes[i - 1].time, "voice stays monophonic (onsets increase)");
}
}
// Voices are pitch-ordered soprano→bass at the opening chord.
const opening = chord4.parts.map((p) => p.notes[0].pitch);
assert.deepEqual(opening, [72, 67, 64, 60], "opening chord splits high→low across S,A,T,B");
// Five notes at the exact same instant: one must go (four blobs can't sing 5).
const chord5 = foldToFourVoices([trk(n(0, 1, 60), n(0, 1, 62), n(0, 1, 64), n(0, 1, 65), n(0, 1, 67))]);
assert.equal(chord5.dropped, 1, "fifth simultaneous note is dropped");
// A single monophonic track still fills all four blobs (fallback clone).
const mono = foldToFourVoices([trk(n(0, 1, 60), n(1, 2, 62))]);
assert.equal(mono.dropped, 0);
assert.ok(mono.parts.every((p) => p.notes.length > 0), "empty voices cloned from the sung one");
console.log(`✓ MIDI fold: 4 voices, ${chord4.dropped}/${chord5.dropped} drops as expected`);
if (!process.argv.includes("--no-network")) {
// Upload the MusicXML-derived recording: it exercises the lyric fields
// (suffix + startSuffix) that the MIDI path never produces.
const id = await upload(xmlBytes);
assert.ok(id && id.length > 0, "server should return a recording id");
console.log(`✓ uploaded — id: ${id}`);
console.log(` play: https://gacembed.withgoogle.com/blob-opera/#/r/${id}`);
}
console.log("All checks passed.");
