112 lines
6.2 KiB
JavaScript
112 lines
6.2 KiB
JavaScript
const crypto = require("crypto");
|
|
|
|
const PROTOCOL_VERSION = 1;
|
|
const AUDIO_MAGIC = Buffer.from("LACP");
|
|
const AUDIO_HEADER_BYTES = 64;
|
|
const MAX_JSON_BYTES = 64 * 1024;
|
|
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
|
|
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack",
|
|
"stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]);
|
|
|
|
function parseEnvelope(input) {
|
|
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
|
if (!bytes.length || bytes.length > MAX_JSON_BYTES) throw protocolError("MESSAGE_SIZE", "Structured message size is invalid.");
|
|
let value;
|
|
try { value = JSON.parse(bytes.toString("utf8")); }
|
|
catch { throw protocolError("INVALID_JSON", "Structured message is not valid JSON."); }
|
|
if (!value || Array.isArray(value) || typeof value !== "object") throw protocolError("INVALID_ENVELOPE", "Message envelope must be an object.");
|
|
if (value.version !== PROTOCOL_VERSION) throw protocolError("INCOMPATIBLE_VERSION", `Protocol version ${value.version} is unsupported.`);
|
|
if (!CLIENT_TYPES.has(value.type)) throw protocolError("UNKNOWN_TYPE", "Message type is not allowed.");
|
|
if (!isUuid(value.id)) throw protocolError("INVALID_ID", "Message id must be a UUID.");
|
|
if (!value.sent_at || !Number.isFinite(Date.parse(value.sent_at))) throw protocolError("INVALID_TIME", "Message sent_at must be an ISO timestamp.");
|
|
if (value.session_id != null && !isUuid(value.session_id)) throw protocolError("INVALID_SESSION", "Session id must be a UUID.");
|
|
if (value.payload != null && (Array.isArray(value.payload) || typeof value.payload !== "object")) throw protocolError("INVALID_PAYLOAD", "Message payload must be an object.");
|
|
return value;
|
|
}
|
|
|
|
function validateHello(envelope) {
|
|
if (envelope.type !== "hello") throw protocolError("HELLO_REQUIRED", "The first message must be hello.");
|
|
const payload = envelope.payload || {};
|
|
if (!shortVersion(payload.companion_version) || !shortVersion(payload.plugin_version)) throw protocolError("INVALID_HELLO", "Companion and plugin versions are required.");
|
|
if (!Array.isArray(payload.capabilities) || payload.capabilities.length > 32) throw protocolError("INVALID_CAPABILITIES", "Capabilities are invalid.");
|
|
if (!payload.capabilities.includes("transcription.capture.v1")) throw protocolError("MISSING_CAPABILITY", "The transcription capture capability is required.");
|
|
const audio = payload.audio || {};
|
|
if (audio.codec !== "pcm_s16le" || audio.sample_rate !== 16000 || audio.channels !== 1 || audio.bits !== 16) {
|
|
throw protocolError("UNSUPPORTED_AUDIO", "Protocol v1 requires 16 kHz mono signed 16-bit PCM.");
|
|
}
|
|
if (payload.resume_session_id != null && !isUuid(payload.resume_session_id)) throw protocolError("INVALID_RESUME", "Resume session id is invalid.");
|
|
return payload;
|
|
}
|
|
|
|
function parseAudioFrame(input) {
|
|
const frame = Buffer.from(input || []);
|
|
if (frame.length < AUDIO_HEADER_BYTES || frame.length > AUDIO_HEADER_BYTES + MAX_AUDIO_PAYLOAD_BYTES) {
|
|
throw protocolError("AUDIO_SIZE", "Audio frame size is invalid.");
|
|
}
|
|
if (!frame.subarray(0, 4).equals(AUDIO_MAGIC)) throw protocolError("AUDIO_MAGIC", "Audio frame magic is invalid.");
|
|
const version = frame.readUInt8(4);
|
|
const flags = frame.readUInt8(5);
|
|
const headerBytes = frame.readUInt16LE(6);
|
|
const payloadBytes = frame.readUInt32LE(60);
|
|
if (version !== PROTOCOL_VERSION || headerBytes !== AUDIO_HEADER_BYTES || frame.length !== headerBytes + payloadBytes) {
|
|
throw protocolError("AUDIO_HEADER", "Audio frame header is invalid.");
|
|
}
|
|
if (payloadBytes > MAX_AUDIO_PAYLOAD_BYTES || payloadBytes % 2 !== 0) throw protocolError("AUDIO_PAYLOAD", "PCM payload length is invalid.");
|
|
if (frame.readUInt32LE(52) !== 16000 || frame.readUInt16LE(56) !== 1 || frame.readUInt16LE(58) !== 16) {
|
|
throw protocolError("UNSUPPORTED_AUDIO", "Audio format is unsupported.");
|
|
}
|
|
return {
|
|
version,
|
|
active: Boolean(flags & 1),
|
|
muted: Boolean(flags & 2),
|
|
sequence: frame.readUInt32LE(8),
|
|
capture_timestamp_us: Number(frame.readBigUInt64LE(12)),
|
|
session_id: bytesToUuid(frame.subarray(20, 36)),
|
|
source_uuid: bytesToUuid(frame.subarray(36, 52)),
|
|
sample_rate: 16000,
|
|
channels: 1,
|
|
bits: 16,
|
|
pcm: frame.subarray(AUDIO_HEADER_BYTES)
|
|
};
|
|
}
|
|
|
|
function encodeAudioFrame(value) {
|
|
const pcm = Buffer.from(value.pcm || []);
|
|
if (pcm.length > MAX_AUDIO_PAYLOAD_BYTES || pcm.length % 2) throw protocolError("AUDIO_PAYLOAD", "PCM payload length is invalid.");
|
|
const frame = Buffer.alloc(AUDIO_HEADER_BYTES + pcm.length);
|
|
AUDIO_MAGIC.copy(frame, 0);
|
|
frame.writeUInt8(PROTOCOL_VERSION, 4);
|
|
frame.writeUInt8((value.active === false ? 0 : 1) | (value.muted ? 2 : 0), 5);
|
|
frame.writeUInt16LE(AUDIO_HEADER_BYTES, 6);
|
|
frame.writeUInt32LE(Number(value.sequence) >>> 0, 8);
|
|
frame.writeBigUInt64LE(BigInt(value.capture_timestamp_us || 0), 12);
|
|
uuidToBytes(value.session_id).copy(frame, 20);
|
|
uuidToBytes(value.source_uuid).copy(frame, 36);
|
|
frame.writeUInt32LE(16000, 52);
|
|
frame.writeUInt16LE(1, 56);
|
|
frame.writeUInt16LE(16, 58);
|
|
frame.writeUInt32LE(pcm.length, 60);
|
|
pcm.copy(frame, AUDIO_HEADER_BYTES);
|
|
return frame;
|
|
}
|
|
|
|
function envelope(type, payload = {}, sessionId = null) {
|
|
return { version: 1, type, id: crypto.randomUUID(), sent_at: new Date().toISOString(), session_id: sessionId, payload };
|
|
}
|
|
|
|
function protocolError(code, message) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function isUuid(value) {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ""));
|
|
}
|
|
|
|
function shortVersion(value) { return typeof value === "string" && value.length > 0 && value.length <= 32; }
|
|
function uuidToBytes(value) { if (!isUuid(value)) throw protocolError("INVALID_UUID", "UUID is invalid."); return Buffer.from(value.replace(/-/g, ""), "hex"); }
|
|
function bytesToUuid(value) { const hex = Buffer.from(value).toString("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }
|
|
|
|
module.exports = { PROTOCOL_VERSION, AUDIO_HEADER_BYTES, MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, parseEnvelope, validateHello, parseAudioFrame, encodeAudioFrame, envelope, protocolError, isUuid };
|