openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
339 lines (338 loc) • 12.9 kB
JavaScript
import { l as toErrorObject } from "../../error-coercion-D_-xJ90S.js";
import "../../error-runtime-Bz9Tw57Z.js";
import { a as resamplePcm, n as createStreamingPcmResampler } from "../../audio-codec-DFkaYkvm.js";
import "../../realtime-voice-provider-CoGArOTN.js";
import { n as OpenAIQuicksilverPendingAudio } from "../../realtime-quicksilver-audio-buffer-ZY82NjqB.js";
import { randomInt } from "node:crypto";
//#region extensions/openai/realtime-quicksilver-peer.runtime.ts
const QUICKSILVER_SAMPLE_RATE = 48e3;
const RELAY_SAMPLE_RATE = 24e3;
const QUICKSILVER_CHANNELS = 2;
const OPUS_FRAME_SAMPLES = 960;
const OPUS_FRAME_DURATION_MS = 20;
const INBOUND_REORDER_DEPTH = 4;
const INBOUND_MAX_LATE_PACKETS = 100;
const RTP_SEQUENCE_MODULUS = 65536;
const RTP_SEQUENCE_HALF_RANGE = RTP_SEQUENCE_MODULUS / 2;
function pcmBufferToInt16(pcm) {
const samples = new Int16Array(Math.floor(pcm.length / 2));
for (let index = 0; index < samples.length; index += 1) samples[index] = pcm.readInt16LE(index * 2);
return samples;
}
function convertRelayPcmToQuicksilverPcm(pcm24kMono) {
return duplicateMonoToStereo(resamplePcm(pcm24kMono, RELAY_SAMPLE_RATE, QUICKSILVER_SAMPLE_RATE));
}
function duplicateMonoToStereo(pcm48kMono) {
const mono48k = pcmBufferToInt16(pcm48kMono);
const stereo48k = new Int16Array(mono48k.length * QUICKSILVER_CHANNELS);
for (let index = 0; index < mono48k.length; index += 1) {
const sample = mono48k[index] ?? 0;
stereo48k[index * 2] = sample;
stereo48k[index * 2 + 1] = sample;
}
return stereo48k;
}
function convertQuicksilverPcmToRelayPcm(pcm48kStereo) {
const frameCount = Math.floor(pcm48kStereo.length / QUICKSILVER_CHANNELS);
const mono48k = Buffer.alloc(frameCount * 2);
for (let frame = 0; frame < frameCount; frame += 1) {
const left = pcm48kStereo[frame * 2] ?? 0;
const right = pcm48kStereo[frame * 2 + 1] ?? 0;
mono48k.writeInt16LE(Math.round((left + right) / 2), frame * 2);
}
return resamplePcm(mono48k, QUICKSILVER_SAMPLE_RATE, RELAY_SAMPLE_RATE);
}
function forwardSequenceDistance(expected, sequenceNumber) {
return sequenceNumber - expected + RTP_SEQUENCE_MODULUS & 65535;
}
/** Pure-TypeScript WebRTC media peer with a WASM-only Opus codec. */
var OpenAIQuicksilverAudioPeer = class OpenAIQuicksilverAudioPeer {
static async create(params) {
const [werift, libopus] = await Promise.all([import("../../lib-BP_E31NY.js"), import("../../dist-Bx4Cc2w8.js")]);
params.signal?.throwIfAborted();
const peer = new werift.RTCPeerConnection({
codecs: {
audio: [werift.useOPUS({ payloadType: 111 })],
video: []
},
...params.iceServers ? { iceServers: params.iceServers } : {}
});
const transceiver = peer.addTransceiver("audio", { direction: "sendrecv" });
let encoder;
let decoder;
let encoderFreed = false;
let decoderFreed = false;
let peerClosed = false;
const cleanup = async () => {
if (encoder && !encoderFreed) {
encoderFreed = true;
encoder.free();
}
if (decoder && !decoderFreed) {
decoderFreed = true;
decoder.free();
}
if (!peerClosed) {
peerClosed = true;
await peer.close().catch(() => void 0);
}
};
const onAbort = () => void cleanup();
params.signal?.addEventListener("abort", onAbort, { once: true });
try {
encoder = await libopus.createEncoder({
application: libopus.Application.Voip,
channels: QUICKSILVER_CHANNELS,
sampleRate: QUICKSILVER_SAMPLE_RATE,
frameSize: OPUS_FRAME_SAMPLES
});
params.signal?.throwIfAborted();
decoder = await libopus.createDecoder({
channels: QUICKSILVER_CHANNELS,
sampleRate: QUICKSILVER_SAMPLE_RATE
});
params.signal?.throwIfAborted();
params.signal?.removeEventListener("abort", onAbort);
return new OpenAIQuicksilverAudioPeer({
callbacks: params.callbacks,
decoder,
encoder,
peer,
transceiver,
werift
});
} catch (error) {
params.signal?.removeEventListener("abort", onAbort);
await cleanup();
throw error;
}
}
static convertRelayPcm(pcm24kMono) {
return convertRelayPcmToQuicksilverPcm(pcm24kMono);
}
static convertQuicksilverPcm(pcm48kStereo) {
return convertQuicksilverPcmToRelayPcm(pcm48kStereo);
}
constructor(state) {
this.state = state;
this.connected = false;
this.closed = false;
this.inboundRtpState = { pendingPackets: /* @__PURE__ */ new Map() };
this.pendingAudio = new OpenAIQuicksilverPendingAudio();
this.pendingResampledAudio = Buffer.alloc(60);
this.inboundResampler = createStreamingPcmResampler(QUICKSILVER_SAMPLE_RATE, RELAY_SAMPLE_RATE);
this.outboundResampler = createStreamingPcmResampler(RELAY_SAMPLE_RATE, QUICKSILVER_SAMPLE_RATE);
this.sequenceNumber = randomInt(65536);
this.subscribedTracks = /* @__PURE__ */ new Set();
this.timestamp = randomInt(4294967296);
state.peer.onTrack.subscribe((track) => this.attachInboundTrack(track));
state.peer.connectionStateChange.subscribe((connectionState) => {
if (this.closed) return;
if (connectionState === "connected") {
this.connected = true;
this.startMediaPump();
} else if ([
"failed",
"disconnected",
"closed"
].includes(connectionState)) {
this.connected = false;
this.state.callbacks.onError(/* @__PURE__ */ new Error(`GPT-Live WebRTC media connection ${connectionState}`));
}
});
}
async createOffer() {
const offer = await this.state.peer.createOffer();
await this.state.peer.setLocalDescription(offer);
const sdp = this.state.peer.localDescription?.sdp;
if (!sdp?.trim()) throw new Error("werift did not produce a GPT-Live SDP offer");
return sdp;
}
async applyAnswer(answerSdp) {
await this.state.peer.setRemoteDescription({
type: "answer",
sdp: answerSdp
});
this.attachInboundTrack(this.state.transceiver.receiver.track);
}
adoptPendingAudio(pendingAudio) {
if (this.closed) {
pendingAudio.clear();
return;
}
if (this.pendingAudio.length > 0) {
pendingAudio.clear();
throw new Error("GPT-Live WebRTC peer already owns pending audio");
}
this.pendingAudio = pendingAudio;
}
sendAudio(audio) {
if (this.closed || audio.length < 2) return;
this.pendingAudio.append(audio);
}
close() {
if (this.closed) return;
this.closed = true;
if (this.mediaTimer) {
clearInterval(this.mediaTimer);
this.mediaTimer = void 0;
}
this.pendingAudio.clear();
this.pendingResampledAudio = Buffer.alloc(0);
this.inboundResampler.flush();
this.outboundResampler.flush();
this.resetInboundRtpState();
this.state.encoder.free();
this.state.decoder.free();
this.state.peer.close().catch(() => void 0);
}
attachInboundTrack(track) {
if (track.kind !== "audio" || this.subscribedTracks.has(track.uuid)) return;
this.subscribedTracks.add(track.uuid);
track.onReceiveRtp.subscribe((packet) => this.handleInboundRtp(packet));
}
handleInboundRtp(packet) {
if (this.closed) return;
try {
this.state.callbacks.onRtpPacket?.();
const sequenceNumber = packet.header.sequenceNumber;
if (this.activeInboundSsrc === void 0) this.activeInboundSsrc = packet.header.ssrc;
else if (packet.header.ssrc !== this.activeInboundSsrc) throw new Error("GPT-Live WebRTC audio source changed unexpectedly");
const state = this.inboundRtpState;
if (state.nextSequence === void 0) {
state.nextSequence = sequenceNumber + 1 & 65535;
this.decodeInboundPacket(packet);
return;
}
const distance = forwardSequenceDistance(state.nextSequence, sequenceNumber);
if (distance >= RTP_SEQUENCE_HALF_RANGE) {
if (forwardSequenceDistance(sequenceNumber, state.nextSequence) <= INBOUND_MAX_LATE_PACKETS) return;
throw new Error("GPT-Live WebRTC RTP sequence changed unexpectedly");
}
if (state.pendingPackets.has(sequenceNumber)) return;
if (distance === 0) {
state.nextSequence = state.nextSequence + 1 & 65535;
this.decodeInboundPacket(packet);
this.clearInboundFlushTimer(state);
this.drainInboundPackets(state);
return;
}
state.pendingPackets.set(sequenceNumber, packet);
this.flushInboundReorderWindow(state);
this.scheduleInboundFlush(state);
} catch (error) {
this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed"));
}
}
resetInboundRtpState() {
this.clearInboundFlushTimer(this.inboundRtpState);
this.inboundRtpState.nextSequence = void 0;
this.inboundRtpState.pendingPackets.clear();
}
flushInboundReorderWindow(state, force = false) {
const expected = state.nextSequence;
if (expected === void 0 || state.pendingPackets.size === 0) return;
const pending = [...state.pendingPackets.keys()].map((sequenceNumber) => ({
sequenceNumber,
distance: forwardSequenceDistance(expected, sequenceNumber)
})).filter(({ distance }) => distance < RTP_SEQUENCE_HALF_RANGE).toSorted((left, right) => left.distance - right.distance);
const nearest = pending[0];
const farthest = pending.at(-1);
if (!nearest || !farthest || !force && farthest.distance < INBOUND_REORDER_DEPTH) return;
this.clearInboundFlushTimer(state);
const concealCount = Math.min(nearest.distance, INBOUND_REORDER_DEPTH);
for (let index = 0; index < concealCount; index += 1) {
state.nextSequence = (state.nextSequence ?? 0) + 1 & 65535;
this.decodeInboundPacketLoss();
}
if (nearest.distance > INBOUND_REORDER_DEPTH) state.nextSequence = nearest.sequenceNumber;
this.drainInboundPackets(state);
}
drainInboundPackets(state) {
while (state.nextSequence !== void 0) {
const packet = state.pendingPackets.get(state.nextSequence);
if (!packet) break;
state.pendingPackets.delete(state.nextSequence);
state.nextSequence = state.nextSequence + 1 & 65535;
this.decodeInboundPacket(packet);
}
if (state.pendingPackets.size === 0) this.clearInboundFlushTimer(state);
else this.scheduleInboundFlush(state);
}
scheduleInboundFlush(state) {
if (this.closed || state.flushTimer || state.pendingPackets.size === 0) return;
state.flushTimer = setTimeout(() => {
state.flushTimer = void 0;
if (this.closed) return;
try {
this.flushInboundReorderWindow(state, true);
} catch (error) {
this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed"));
}
}, 80);
state.flushTimer.unref?.();
}
clearInboundFlushTimer(state) {
if (state.flushTimer) {
clearTimeout(state.flushTimer);
state.flushTimer = void 0;
}
}
decodeInboundPacket(packet) {
const opusPacket = this.state.werift.dePacketizeRtpPackets("opus", [packet]).data;
this.emitInboundPcm(this.state.decoder.decode(opusPacket, { maxFrameSize: 5760 }));
}
decodeInboundPacketLoss() {
this.emitInboundPcm(this.state.decoder.decodePacketLoss(OPUS_FRAME_SAMPLES));
}
emitInboundPcm(decoded) {
const frameCount = Math.floor(decoded.length / QUICKSILVER_CHANNELS);
const mono48k = Buffer.alloc(frameCount * 2);
for (let frame = 0; frame < frameCount; frame += 1) {
const left = decoded[frame * 2] ?? 0;
const right = decoded[frame * 2 + 1] ?? 0;
mono48k.writeInt16LE(Math.round((left + right) / 2), frame * 2);
}
const relayPcm = this.inboundResampler.process(mono48k);
if (relayPcm.length > 0) this.state.callbacks.onAudio(relayPcm);
}
startMediaPump() {
if (this.mediaTimer || this.closed) return;
this.mediaTimer = setInterval(() => this.sendNextAudioFrame(), OPUS_FRAME_DURATION_MS);
this.mediaTimer.unref?.();
this.sendNextAudioFrame();
}
sendNextAudioFrame() {
if (!this.connected || this.closed) return;
const frame = this.takeNextRelayFrame();
try {
const resampled = this.outboundResampler.process(frame);
this.pendingResampledAudio = Buffer.concat([this.pendingResampledAudio, resampled]);
const monoFrameBytes = OPUS_FRAME_SAMPLES * 2;
const monoFrame = Buffer.alloc(monoFrameBytes);
this.pendingResampledAudio.copy(monoFrame, 0, 0, monoFrameBytes);
this.pendingResampledAudio = Buffer.from(this.pendingResampledAudio.subarray(monoFrameBytes));
const opusPacket = this.state.encoder.encode(duplicateMonoToStereo(monoFrame), { frameSize: OPUS_FRAME_SAMPLES });
const rtp = new this.state.werift.RtpPacket(new this.state.werift.RtpHeader({
marker: false,
payloadType: 111,
sequenceNumber: this.sequenceNumber,
timestamp: this.timestamp
}), Buffer.from(opusPacket));
this.sequenceNumber = this.sequenceNumber + 1 & 65535;
this.timestamp = this.timestamp + OPUS_FRAME_SAMPLES >>> 0;
this.state.transceiver.sender.sendRtp(rtp).catch((error) => {
this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed"));
});
} catch (error) {
this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed"));
}
}
takeNextRelayFrame() {
const frame = Buffer.alloc(960);
this.pendingAudio.readInto(frame);
return frame;
}
};
//#endregion
export { OpenAIQuicksilverAudioPeer };