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"]); 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 };