Lumi/plugins/lumi_transcription/backend/companion/protocol.js
2026-07-22 11:01:49 +02:00

111 lines
6.1 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", "start", "stop", "ack"]);
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 };