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();
}
