UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

314 lines (313 loc) • 13.7 kB
import "./src-CdRlGRnW.mjs"; import { t as expectDefined } from "./expect-lbe3Hgrh.mjs"; //#region src/talk/audio-codec.ts /** * PCM resampling and G.711 mu-law conversion helpers for Talk audio bridges. * * Telephony providers generally expect 8 kHz mu-law frames, while local audio * capture and realtime providers can produce higher-rate signed 16-bit PCM. */ const TELEPHONY_SAMPLE_RATE = 8e3; const RESAMPLE_FILTER_TAPS = 31; const RESAMPLE_CUTOFF_GUARD = .94; const RESAMPLE_MAX_PRECOMPUTED_PHASES = 4096; const RESAMPLE_HALF_TAPS = Math.floor(RESAMPLE_FILTER_TAPS / 2); const RESAMPLE_WINDOW = Array.from({ length: RESAMPLE_FILTER_TAPS }, (_, tapIndex) => .5 - .5 * Math.cos(2 * Math.PI * tapIndex / 30)); const HOST_IS_LITTLE_ENDIAN = new Uint16Array(new Uint8Array([1, 0]).buffer)[0] === 1; /** Clamp an intermediate sample to signed 16-bit PCM range. */ function clamp16(value) { return Math.max(-32768, Math.min(32767, value)); } function canUseInt16View(buffer) { return HOST_IS_LITTLE_ENDIAN && buffer.byteOffset % Int16Array.BYTES_PER_ELEMENT === 0; } function int16View(buffer) { return new Int16Array(buffer.buffer, buffer.byteOffset, Math.floor(buffer.byteLength / Int16Array.BYTES_PER_ELEMENT)); } function readInt16Samples(buffer) { if (canUseInt16View(buffer)) return int16View(buffer); const samples = new Int16Array(Math.floor(buffer.byteLength / Int16Array.BYTES_PER_ELEMENT)); for (let i = 0; i < samples.length; i += 1) samples[i] = buffer.readInt16LE(i * Int16Array.BYTES_PER_ELEMENT); return samples; } function sinc(x) { if (x === 0) return 1; return Math.sin(Math.PI * x) / (Math.PI * x); } function gcd(left, right) { let a = Math.abs(Math.trunc(left)); let b = Math.abs(Math.trunc(right)); while (b !== 0) { const next = a % b; a = b; b = next; } return a || 1; } function buildResampleKernel(inputSampleRate, outputSampleRate, cutoffCyclesPerSample) { if (!Number.isInteger(inputSampleRate) || !Number.isInteger(outputSampleRate)) return; const divisor = gcd(inputSampleRate, outputSampleRate); const inputStep = inputSampleRate / divisor; const phaseCount = outputSampleRate / divisor; if (phaseCount > RESAMPLE_MAX_PRECOMPUTED_PHASES) return; return { coefficients: Array.from({ length: phaseCount }, (_, phaseIndex) => { const phase = phaseIndex / phaseCount; const phaseCoefficients = new Float64Array(RESAMPLE_FILTER_TAPS); let weightSum = 0; for (let tap = -15; tap <= RESAMPLE_HALF_TAPS; tap += 1) { const distance = tap - phase; const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance); const tapIndex = tap + RESAMPLE_HALF_TAPS; const coefficient = lowPass * (RESAMPLE_WINDOW[tapIndex] ?? 0); phaseCoefficients[tapIndex] = coefficient; weightSum += coefficient; } return { coefficients: phaseCoefficients, weightSum }; }), inputStep, phaseCount }; } function sampleBandlimitedWithCoefficients(input, center, phase) { const { coefficients } = phase; let weighted = 0; if (center >= RESAMPLE_HALF_TAPS && center + RESAMPLE_HALF_TAPS < input.length && phase.weightSum !== 0) { for (let tap = -15; tap <= RESAMPLE_HALF_TAPS; tap += 1) weighted += (input[center + tap] ?? 0) * (coefficients[tap + RESAMPLE_HALF_TAPS] ?? 0); return weighted / phase.weightSum; } let weightSum = 0; for (let tap = -15; tap <= RESAMPLE_HALF_TAPS; tap += 1) { const sampleIndex = center + tap; if (sampleIndex < 0 || sampleIndex >= input.length) continue; const coeff = coefficients[tap + RESAMPLE_HALF_TAPS] ?? 0; weighted += (input[sampleIndex] ?? 0) * coeff; weightSum += coeff; } if (weightSum === 0) return input[Math.max(0, Math.min(input.length - 1, center))] ?? 0; return weighted / weightSum; } function sampleBandlimited(input, srcPos, cutoffCyclesPerSample) { const center = Math.floor(srcPos); let weighted = 0; let weightSum = 0; for (let tap = -15; tap <= RESAMPLE_HALF_TAPS; tap += 1) { const sampleIndex = center + tap; if (sampleIndex < 0 || sampleIndex >= input.length) continue; const distance = sampleIndex - srcPos; const coeff = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance) * (RESAMPLE_WINDOW[tap + RESAMPLE_HALF_TAPS] ?? 0); weighted += (input[sampleIndex] ?? 0) * coeff; weightSum += coeff; } if (weightSum === 0) return input[Math.max(0, Math.min(input.length - 1, Math.round(srcPos)))] ?? 0; return weighted / weightSum; } function createResamplePlan(inputSampleRate, outputSampleRate) { const ratio = inputSampleRate / outputSampleRate; const maxCutoff = .5; const downsampleCutoff = ratio > 1 ? maxCutoff / ratio : maxCutoff; const cutoffCyclesPerSample = Math.max(.01, downsampleCutoff * RESAMPLE_CUTOFF_GUARD); return { cutoffCyclesPerSample, inputSampleRate, kernel: buildResampleKernel(inputSampleRate, outputSampleRate, cutoffCyclesPerSample), outputSampleRate, ratio }; } function sampleResampledPcm(input, inputStartSample, outputIndex, plan) { const sourcePosition = outputIndex * plan.inputSampleRate / plan.outputSampleRate; return Math.round(plan.kernel ? sampleBandlimitedWithCoefficients(input, Math.floor(sourcePosition) - inputStartSample, expectDefined(plan.kernel.coefficients[outputIndex * plan.kernel.inputStep % plan.kernel.phaseCount], "coefficients entry at (output index * kernel input step) % kernel phase count")) : sampleBandlimited(input, outputIndex * plan.ratio - inputStartSample, plan.cutoffCyclesPerSample)); } function renderResampledPcm(input, inputStartSample, firstOutputIndex, outputSamples, plan) { const output = Buffer.alloc(outputSamples * 2); const inputView = readInt16Samples(input); const outputView = canUseInt16View(output) ? int16View(output) : void 0; for (let offset = 0; offset < outputSamples; offset += 1) { const sample = clamp16(sampleResampledPcm(inputView, inputStartSample, firstOutputIndex + offset, plan)); if (outputView) outputView[offset] = sample; else output.writeInt16LE(sample, offset * 2); } return output; } /** Resample little-endian signed 16-bit PCM to another integer sample rate. */ function resamplePcm(input, inputSampleRate, outputSampleRate) { if (inputSampleRate === outputSampleRate) return input; const inputSamples = Math.floor(input.length / 2); if (inputSamples === 0) return Buffer.alloc(0); const plan = createResamplePlan(inputSampleRate, outputSampleRate); return renderResampledPcm(input, 0, 0, Math.floor(inputSamples / plan.ratio), plan); } /** Create a chunk-safe PCM resampler that preserves filter and fractional phase state. */ function createStreamingPcmResampler(inputSampleRate, outputSampleRate) { if (inputSampleRate === outputSampleRate) return { process: (chunk) => Buffer.from(chunk), flush: () => Buffer.alloc(0) }; const plan = createResamplePlan(inputSampleRate, outputSampleRate); let bufferedInput = Buffer.alloc(0); let inputStartSample = 0; let totalInputSamples = 0; let nextOutputIndex = 0; let trailingByte = Buffer.alloc(0); let flushed = false; const renderAvailable = (includeRightEdge) => { const targetOutputCount = Math.floor(totalInputSamples / plan.ratio); let endOutputIndex = nextOutputIndex; while (endOutputIndex < targetOutputCount) { const center = Math.floor(endOutputIndex * plan.inputSampleRate / plan.outputSampleRate); if (!includeRightEdge && center + RESAMPLE_HALF_TAPS >= totalInputSamples) break; endOutputIndex += 1; } const output = renderResampledPcm(bufferedInput, inputStartSample, nextOutputIndex, endOutputIndex - nextOutputIndex, plan); nextOutputIndex = endOutputIndex; const nextCenter = Math.floor(nextOutputIndex * plan.inputSampleRate / plan.outputSampleRate); const retainFromSample = Math.max(0, nextCenter - RESAMPLE_HALF_TAPS); const dropSamples = retainFromSample - inputStartSample; if (dropSamples > 0) { bufferedInput = Buffer.from(bufferedInput.subarray(dropSamples * 2)); inputStartSample = retainFromSample; } return output; }; return { process(chunk) { if (flushed) throw new Error("Cannot process PCM after the streaming resampler was flushed"); const combined = trailingByte.length > 0 ? Buffer.concat([trailingByte, chunk]) : chunk; const completeBytes = combined.length - combined.length % 2; trailingByte = Buffer.from(combined.subarray(completeBytes)); if (completeBytes > 0) { const completePcm = combined.subarray(0, completeBytes); bufferedInput = bufferedInput.length > 0 ? Buffer.concat([bufferedInput, completePcm]) : Buffer.from(completePcm); totalInputSamples += completeBytes / 2; } return renderAvailable(false); }, flush() { if (flushed) return Buffer.alloc(0); flushed = true; trailingByte = Buffer.alloc(0); const output = renderAvailable(true); bufferedInput = Buffer.alloc(0); return output; } }; } /** Resample little-endian signed 16-bit PCM to the telephony 8 kHz rate. */ function resamplePcmTo8k(input, inputSampleRate) { return resamplePcm(input, inputSampleRate, TELEPHONY_SAMPLE_RATE); } /** Convert little-endian signed 16-bit PCM samples to G.711 mu-law bytes. */ function pcmToMulaw(pcm) { const pcmView = readInt16Samples(pcm); const mulaw = Buffer.alloc(pcmView.length); for (let i = 0; i < pcmView.length; i += 1) mulaw[i] = linearToMulaw(pcmView[i] ?? 0); return mulaw; } /** Expand G.711 mu-law bytes into little-endian signed 16-bit PCM samples. */ function mulawToPcm(mulaw) { const pcm = Buffer.alloc(mulaw.length * 2); const pcmView = canUseInt16View(pcm) ? int16View(pcm) : void 0; if (pcmView) { for (let i = 0; i < mulaw.length; i += 1) pcmView[i] = clamp16(mulawToLinear(mulaw[i] ?? 0)); return pcm; } for (let i = 0; i < mulaw.length; i += 1) pcm.writeInt16LE(clamp16(mulawToLinear(mulaw[i] ?? 0)), i * 2); return pcm; } /** Resample signed 16-bit PCM to 8 kHz and encode it as G.711 mu-law. */ function convertPcmToMulaw8k(pcm, inputSampleRate) { return pcmToMulaw(resamplePcmTo8k(pcm, inputSampleRate)); } function linearToMulaw(sampleInput) { let sample = sampleInput; const BIAS = 132; const CLIP = 32635; const sign = sample < 0 ? 128 : 0; if (sample < 0) sample = -sample; if (sample > CLIP) sample = CLIP; sample += BIAS; let exponent = 7; for (let expMask = 16384; (sample & expMask) === 0 && exponent > 0; exponent -= 1) expMask >>= 1; const mantissa = sample >> exponent + 3 & 15; return ~(sign | exponent << 4 | mantissa) & 255; } function mulawToLinear(value) { const muLaw = ~value & 255; const sign = muLaw & 128; const exponent = muLaw >> 4 & 7; let sample = ((muLaw & 15) << 3) + 132 << exponent; sample -= 132; return sign ? -sample : sample; } //#endregion //#region src/talk/audio-energy.ts const PCM16_MAX_AMPLITUDE = 32768; const MULAW_LINEAR_SAMPLES = (() => { const encoded = Buffer.from([...Array(256).keys()]); const decoded = mulawToPcm(encoded); return Int16Array.from(encoded, (_, index) => decoded.readInt16LE(index * 2)); })(); /** Ignore decoded transport silence below -66 dBFS while retaining quiet speech. */ function isRealtimeVoiceAudioAudible(audio, format) { const minimumPeak = 16; if (format.encoding === "g711_ulaw") return audio.some((sample) => Math.abs(MULAW_LINEAR_SAMPLES[sample] ?? 0) >= minimumPeak); for (let offset = 0; offset + 1 < audio.byteLength; offset += 2) if (Math.abs(audio.readInt16LE(offset)) >= minimumPeak) return true; return false; } /** Read RMS and absolute peak from complete little-endian signed PCM16 samples. */ function readPcm16AudioStats(audio) { let sumSquares = 0; let peak = 0; const samples = Math.floor(audio.byteLength / 2); for (let index = 0; index < samples; index += 1) { const sample = audio.readInt16LE(index * 2); peak = Math.max(peak, Math.abs(sample)); sumSquares += sample * sample; } return { rms: samples > 0 ? Math.sqrt(sumSquares / samples) : 0, peak }; } /** Calculate normalized RMS from G.711 mu-law bytes. */ function calculateMulawRms(muLaw) { if (muLaw.length === 0) return 0; let sumSquares = 0; for (const encoded of muLaw) { const normalized = (MULAW_LINEAR_SAMPLES[encoded] ?? 0) / PCM16_MAX_AMPLITUDE; sumSquares += normalized * normalized; } return Math.sqrt(sumSquares / muLaw.length); } /** Build an OR-threshold gate with optional sustained onset, silence hold, and cooldown. */ function createSpeechThresholdGate(options) { const speechFrames = Math.max(1, Math.floor(options.speechFrames ?? 1)); const silenceFrames = Math.max(0, Math.floor(options.silenceFrames ?? 0)); const cooldownMs = Math.max(0, options.cooldownMs ?? 0); let loudFrames = 0; let quietFrames = 0; let speaking = false; let lastTriggerAt = Number.NEGATIVE_INFINITY; return { accept(stats, acceptOptions = {}) { if (!(options.rmsThreshold !== void 0 && stats.rms >= options.rmsThreshold || options.peakThreshold !== void 0 && stats.peak >= options.peakThreshold)) { loudFrames = 0; if (speaking && ++quietFrames >= silenceFrames) speaking = false; return false; } quietFrames = 0; loudFrames += 1; if (speaking || loudFrames < speechFrames) return false; const nowMs = acceptOptions.nowMs ?? Date.now(); if (nowMs - lastTriggerAt < cooldownMs || acceptOptions.onTrigger?.() === false) return false; lastTriggerAt = nowMs; speaking = silenceFrames > 0; if (!speaking) loudFrames = 0; return true; } }; } //#endregion export { convertPcmToMulaw8k as a, pcmToMulaw as c, readPcm16AudioStats as i, resamplePcm as l, createSpeechThresholdGate as n, createStreamingPcmResampler as o, isRealtimeVoiceAudioAudible as r, mulawToPcm as s, calculateMulawRms as t, resamplePcmTo8k as u };