HomeMAL' // MANUFACTORUM
datacrypt
Blob Opera MIDI / public / lib / protobuf.js
public/lib/protobuf.js3.1 KB
// Hand-rolled encoder for the Blob Opera `RecordingMessage` protobuf.
//
// Schema (proto2, from 0x2b3bfa0/python-blobopera):
//
//   message RecordingMessage {
//     message Part      { repeated Note notes = 1; repeated PhonemeWithDuration startSuffix = 2; }
//     message Note      { required float timeSeconds = 1; required float midiPitch = 2;
//                         required LibrettoChunk librettoChunk = 3; optional bool controlled = 4; }
//     message LibrettoChunk        { required PhonemeWithDuration vowel = 1;
//                                    repeated PhonemeWithDuration suffix = 2; }
//     message PhonemeWithDuration  { required Phoneme name = 1; required float duration = 2; }
//     required Theme theme = 1;      // 0 = default
//     repeated Part parts = 2;       // exactly 4: soprano, alto, tenor, bass
//     required Location location = 3;// 0 = default
//   }
//
// `theme` and `location` are *required* in proto2 — omitting them makes the
// server reject the upload with HTTP 400.

// A tiny append-only byte writer.
class Writer {
  constructor() {
    this.bytes = [];
  }
  varint(n) {
    n >>>= 0;
    while (n > 0x7f) {
      this.bytes.push((n & 0x7f) | 0x80);
      n >>>= 7;
    }
    this.bytes.push(n);
    return this;
  }
  tag(field, wire) {
    return this.varint((field << 3) | wire);
  }
  float(field, value) {
    this.tag(field, 5); // wire type 5 = fixed32
    const buf = new DataView(new ArrayBuffer(4));
    buf.setFloat32(0, value, true); // little-endian
    for (let i = 0; i < 4; i++) this.bytes.push(buf.getUint8(i));
    return this;
  }
  int(field, value) {
    return this.tag(field, 0).varint(value);
  }
  sub(field, writeFn) {
    const inner = new Writer();
    writeFn(inner);
    this.tag(field, 2).varint(inner.bytes.length);
    for (const b of inner.bytes) this.bytes.push(b);
    return this;
  }
  toUint8Array() {
    return new Uint8Array(this.bytes);
  }
}

function writePhoneme(w, timed) {
  w.int(1, timed.phoneme).float(2, timed.duration);
}

// One Note: pitch + timing + its librettoChunk (vowel and consonant suffix).
function writeNote(w, note) {
  w.float(1, note.time);
  w.float(2, note.pitch);
  w.sub(3, (chunk) => {
    chunk.sub(1, (v) => writePhoneme(v, note.vowel));
    for (const s of note.suffix) chunk.sub(2, (x) => writePhoneme(x, s));
  });
}

/**
 * Encode a recording into `RecordingMessage` protobuf bytes.
 * @param {Array<{notes:Array<{time,pitch,vowel:{phoneme,duration},suffix:Array}>,
 *                startSuffix:Array<{phoneme,duration}>}>} parts
 *        Exactly four parts in [soprano, alto, tenor, bass] order.
 * @returns {Uint8Array}
 */
export function encodeRecording(parts) {
  if (parts.length !== 4) throw new Error(`expected 4 parts, got ${parts.length}`);
  const w = new Writer();
  w.int(1, 0); // theme (required)
  for (const part of parts) {
    w.sub(2, (p) => {
      for (const note of part.notes) p.sub(1, (n) => writeNote(n, note));
      for (const timed of part.startSuffix) p.sub(2, (x) => writePhoneme(x, timed));
    });
  }
  w.int(3, 0); // location (required)
  return w.toUint8Array();
}