mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 01:01:19 +00:00
* fix(core): make indexed access explicit in auto-reply, infra, and config Part 1/3 of the src NUIA phase-3b burn-down (#104600): iteration and destructuring over index reads, boundary guards on parsed input, and named invariants. Config path walkers bind the path head once; SQLite migration key handling is hoisted without query-shape changes. * fix(core): make indexed access explicit in cli, gateway, commands, security, shared Part 2/3: argv/token selection restructured, gateway event/attachment invariants named, security parsers stay fail-closed (invariant violations throw), edit-distance matrices access checked entries. * fix(core): make indexed access explicit across remaining src surfaces Part 3/3: channels, plugins, process, cron, plugin-sdk, media, logging, tui, hooks, daemon, and small directories. Latent bug fixed: a tailnet resolver could leak undefined through a string|null contract and now fails with a descriptive local error. * fix(core): keep optional boundaries optional after per-commit review Review findings: expectDefined misused where absence is a legitimate state. CLI --profile/route-args missing next tokens take their existing miss paths; help normalization compares --help against the last positional again; first-time plugin install spreads absent cfg.plugins; denylist scan iterates manifest dependency entries instead of throwing on omitted sections; tailnet resolver returns a guaranteed string at the source instead of a caller-side undefined throw. * refactor(core): closed-key provider labels and honest optional passthroughs PROVIDER_LABELS becomes a satisfies-typed closed record (static reads provably defined; dynamic lookups go through providerUsageLabel with honest string|undefined). Status-scan overview passes its optional params through unchanged instead of asserting them. * fix(channels): make getChatChannelMeta honestly optional The original signature claimed ChatChannelMeta while leaking undefined on bundled channel id metadata drift; three of four callers already handled absence. The return type now says so, and the one assuming caller falls back to the raw channel label. * fix(core): index-safety for post-rebase main drift Covers the sqlite-sessions flip and auth-source-plan code that landed mid-phase, plus the channel-validation test consuming the now honestly optional getChatChannelMeta. * refactor(channels): split chat-meta accessors along the SDK contract getChatChannelMeta keeps its shipped plugin-SDK signature (defined for bundled ids, fail-loud on impossible misses); new findChatChannelMeta carries the drift-tolerant optional contract for core auto-enable and formatting paths. * fix(qa-channel): own channel metadata instead of a guaranteed-undefined catalog lookup qa-channel spread getChatChannelMeta over an id that is never in the bundled catalog, shipping an empty setup meta by accident; the fail-loud SDK accessor exposed it. The channel now declares its metadata once. * fix(gateway): heartbeat projection lookahead is optional at the transcript tail expectDefined wrapped messages[i + 1] whose absence on the final message is the normal case; the adjacent ternary already handled it. Restores the plain optional read with an explicit guard in the pair condition. * fix(plugin-sdk): channel plugin factory tolerates non-bundled channel ids again createChannelPluginBase spreads bundled catalog meta for ANY channel id, where absence is the normal case for external plugins; the resolver is honestly optional again while the exported bundled-id accessor keeps the fail-loud contract. * fix(core): spreads of optional config sections stay optional Fresh-setup and first-install paths (crestodian setup inference, hook installs, agent config base, target agent models) legitimately lack the section being rebuilt; spreading undefined is the shipped {} semantics. Removes the remaining gratuitous assertion wraps found by tree audit.
282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
import { expectDefined } from "@openclaw/normalization-core"; /**
|
|
* 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 = 8000;
|
|
const RESAMPLE_FILTER_TAPS = 31;
|
|
const RESAMPLE_CUTOFF_GUARD = 0.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) => 0.5 - 0.5 * Math.cos((2 * Math.PI * tapIndex) / (RESAMPLE_FILTER_TAPS - 1)),
|
|
);
|
|
|
|
type ResampleKernel = {
|
|
coefficients: readonly Float64Array[];
|
|
inputStep: number;
|
|
phaseCount: number;
|
|
};
|
|
|
|
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: number): number {
|
|
return Math.max(-32768, Math.min(32767, value));
|
|
}
|
|
|
|
// When the host and Buffer alignment allow it, an Int16Array view avoids copying
|
|
// every PCM sample. The fallback below preserves correctness for odd offsets.
|
|
function canUseInt16View(buffer: Buffer): boolean {
|
|
return HOST_IS_LITTLE_ENDIAN && buffer.byteOffset % Int16Array.BYTES_PER_ELEMENT === 0;
|
|
}
|
|
|
|
function int16View(buffer: Buffer): Int16Array {
|
|
return new Int16Array(
|
|
buffer.buffer,
|
|
buffer.byteOffset,
|
|
Math.floor(buffer.byteLength / Int16Array.BYTES_PER_ELEMENT),
|
|
);
|
|
}
|
|
|
|
function readInt16Samples(buffer: Buffer): Int16Array {
|
|
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: number): number {
|
|
if (x === 0) {
|
|
return 1;
|
|
}
|
|
return Math.sin(Math.PI * x) / (Math.PI * x);
|
|
}
|
|
|
|
function gcd(left: number, right: number): number {
|
|
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: number,
|
|
outputSampleRate: number,
|
|
cutoffCyclesPerSample: number,
|
|
): ResampleKernel | undefined {
|
|
if (!Number.isInteger(inputSampleRate) || !Number.isInteger(outputSampleRate)) {
|
|
return undefined;
|
|
}
|
|
const divisor = gcd(inputSampleRate, outputSampleRate);
|
|
const inputStep = inputSampleRate / divisor;
|
|
const phaseCount = outputSampleRate / divisor;
|
|
if (phaseCount > RESAMPLE_MAX_PRECOMPUTED_PHASES) {
|
|
// Very unusual rate ratios would allocate too many phase tables; callers
|
|
// fall back to the direct bandlimited sampler instead.
|
|
return undefined;
|
|
}
|
|
const coefficients = Array.from({ length: phaseCount }, (_, phaseIndex) => {
|
|
const phase = phaseIndex / phaseCount;
|
|
const phaseCoefficients = new Float64Array(RESAMPLE_FILTER_TAPS);
|
|
for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
|
|
const distance = tap - phase;
|
|
const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance);
|
|
const tapIndex = tap + RESAMPLE_HALF_TAPS;
|
|
phaseCoefficients[tapIndex] = lowPass * (RESAMPLE_WINDOW[tapIndex] ?? 0);
|
|
}
|
|
return phaseCoefficients;
|
|
});
|
|
return { coefficients, inputStep, phaseCount };
|
|
}
|
|
|
|
// Samples through a precomputed windowed-sinc kernel for common rate ratios.
|
|
function sampleBandlimitedWithCoefficients(
|
|
input: Int16Array,
|
|
center: number,
|
|
coefficients: Float64Array,
|
|
): number {
|
|
let weighted = 0;
|
|
let weightSum = 0;
|
|
|
|
for (let tap = -RESAMPLE_HALF_TAPS; 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) {
|
|
const nearest = Math.max(0, Math.min(input.length - 1, center));
|
|
return input[nearest] ?? 0;
|
|
}
|
|
|
|
return weighted / weightSum;
|
|
}
|
|
|
|
// Direct windowed-sinc sampler used when precomputing phase tables is too large.
|
|
function sampleBandlimited(
|
|
input: Int16Array,
|
|
srcPos: number,
|
|
cutoffCyclesPerSample: number,
|
|
): number {
|
|
const center = Math.floor(srcPos);
|
|
let weighted = 0;
|
|
let weightSum = 0;
|
|
|
|
for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
|
|
const sampleIndex = center + tap;
|
|
if (sampleIndex < 0 || sampleIndex >= input.length) {
|
|
continue;
|
|
}
|
|
|
|
const distance = sampleIndex - srcPos;
|
|
const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance);
|
|
const coeff = lowPass * (RESAMPLE_WINDOW[tap + RESAMPLE_HALF_TAPS] ?? 0);
|
|
weighted += (input[sampleIndex] ?? 0) * coeff;
|
|
weightSum += coeff;
|
|
}
|
|
|
|
if (weightSum === 0) {
|
|
const nearest = Math.max(0, Math.min(input.length - 1, Math.round(srcPos)));
|
|
return input[nearest] ?? 0;
|
|
}
|
|
|
|
return weighted / weightSum;
|
|
}
|
|
|
|
/** Resample little-endian signed 16-bit PCM to another integer sample rate. */
|
|
export function resamplePcm(
|
|
input: Buffer,
|
|
inputSampleRate: number,
|
|
outputSampleRate: number,
|
|
): Buffer {
|
|
if (inputSampleRate === outputSampleRate) {
|
|
return input;
|
|
}
|
|
const inputSamples = Math.floor(input.length / 2);
|
|
if (inputSamples === 0) {
|
|
return Buffer.alloc(0);
|
|
}
|
|
|
|
const ratio = inputSampleRate / outputSampleRate;
|
|
const outputSamples = Math.floor(inputSamples / ratio);
|
|
const output = Buffer.alloc(outputSamples * 2);
|
|
const maxCutoff = 0.5;
|
|
const downsampleCutoff = ratio > 1 ? maxCutoff / ratio : maxCutoff;
|
|
const cutoffCyclesPerSample = Math.max(0.01, downsampleCutoff * RESAMPLE_CUTOFF_GUARD);
|
|
const kernel = buildResampleKernel(inputSampleRate, outputSampleRate, cutoffCyclesPerSample);
|
|
|
|
const inputView = readInt16Samples(input);
|
|
const outputView = canUseInt16View(output) ? int16View(output) : undefined;
|
|
|
|
for (let i = 0; i < outputSamples; i += 1) {
|
|
const sample = Math.round(
|
|
kernel
|
|
? sampleBandlimitedWithCoefficients(
|
|
inputView,
|
|
Math.floor((i * inputSampleRate) / outputSampleRate),
|
|
expectDefined(
|
|
kernel.coefficients[(i * kernel.inputStep) % kernel.phaseCount],
|
|
"coefficients entry at (i * kernel.input step) % kernel.phase count",
|
|
) ?? kernel.coefficients[0],
|
|
)
|
|
: sampleBandlimited(inputView, i * ratio, cutoffCyclesPerSample),
|
|
);
|
|
if (outputView) {
|
|
outputView[i] = clamp16(sample);
|
|
} else {
|
|
output.writeInt16LE(clamp16(sample), i * 2);
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/** Resample little-endian signed 16-bit PCM to the telephony 8 kHz rate. */
|
|
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
|
|
return resamplePcm(input, inputSampleRate, TELEPHONY_SAMPLE_RATE);
|
|
}
|
|
|
|
/** Convert little-endian signed 16-bit PCM samples to G.711 mu-law bytes. */
|
|
export function pcmToMulaw(pcm: Buffer): Buffer {
|
|
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. */
|
|
export function mulawToPcm(mulaw: Buffer): Buffer {
|
|
const pcm = Buffer.alloc(mulaw.length * 2);
|
|
const pcmView = canUseInt16View(pcm) ? int16View(pcm) : undefined;
|
|
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. */
|
|
export function convertPcmToMulaw8k(pcm: Buffer, inputSampleRate: number): Buffer {
|
|
return pcmToMulaw(resamplePcmTo8k(pcm, inputSampleRate));
|
|
}
|
|
|
|
// ITU G.711-style mu-law companding. The bias and clip constants intentionally
|
|
// match the standard table formula so round-trips remain provider-compatible.
|
|
function linearToMulaw(sampleInput: number): number {
|
|
let sample = sampleInput;
|
|
const BIAS = 132;
|
|
const CLIP = 32635;
|
|
|
|
const sign = sample < 0 ? 0x80 : 0;
|
|
if (sample < 0) {
|
|
sample = -sample;
|
|
}
|
|
if (sample > CLIP) {
|
|
sample = CLIP;
|
|
}
|
|
|
|
sample += BIAS;
|
|
let exponent = 7;
|
|
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent -= 1) {
|
|
expMask >>= 1;
|
|
}
|
|
|
|
const mantissa = (sample >> (exponent + 3)) & 0x0f;
|
|
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
|
}
|
|
|
|
function mulawToLinear(value: number): number {
|
|
const muLaw = ~value & 0xff;
|
|
const sign = muLaw & 0x80;
|
|
const exponent = (muLaw >> 4) & 0x07;
|
|
const mantissa = muLaw & 0x0f;
|
|
let sample = ((mantissa << 3) + 132) << exponent;
|
|
sample -= 132;
|
|
return sign ? -sample : sample;
|
|
}
|