|
const fs = require('node:fs'); |
|
const path = require('node:path'); |
|
const { createRequire } = require('node:module'); |
|
const requireSdk = createRequire(process.env.AEC_PACKAGE_JSON || path.join(__dirname, 'package.json')); |
|
const sdkDirectory = path.dirname(requireSdk.resolve('@livekit/rtc-node')); |
|
const { FfiClient, FfiHandle } = require(path.join(sdkDirectory, 'ffi_client.cjs')); |
|
const client = FfiClient.instance; |
|
const version = JSON.parse(fs.readFileSync(path.join(sdkDirectory, '../package.json'), 'utf8')).version; |
|
const fixtures = require('./fixtures.json'); |
|
const crypto = require('node:crypto'); |
|
function loadFixture(name) { |
|
const pcm = Buffer.from(fixtures[name].data, 'base64'); |
|
if (crypto.createHash('sha256').update(pcm).digest('hex') !== fixtures[name].sha256) throw new Error('Fixture checksum mismatch'); |
|
return pcm; |
|
} |
|
const caller = loadFixture('caller.pcm'); |
|
const bot = loadFixture('bot.pcm'); |
|
const RATE = 8000; |
|
function request(type, value) { |
|
const response = client.request({message: {case: type, value}}); |
|
if (response.error) throw new Error(response.error); |
|
return response; |
|
} |
|
function frame(type, handle, pcm) { |
|
const data = Uint8Array.from(pcm); |
|
request(type, {apmHandle: handle, dataPtr: client.retrievePtr(data), size: data.length, sampleRate: RATE, numChannels: 1}); |
|
return Buffer.from(data); |
|
} |
|
function energy(pcm, start, length) { |
|
let total = 0; |
|
for (let i = start; i < start + length; i++) total += pcm.readInt16LE(i * 2) ** 2; |
|
return total / length; |
|
} |
|
function speechQuality(output, onset) { |
|
let best = {score: -Infinity}; |
|
for (let lag = 0; lag <= 160; lag++) { |
|
let xx = 0, xy = 0, yy = 0; |
|
for (let i = 0; i < RATE; i++) { |
|
const x = caller.readInt16LE(i * 2), y = output.readInt16LE((onset * RATE + i + lag) * 2); |
|
xx += x * x; xy += x * y; yy += y * y; |
|
} |
|
const score = xy * xy / xx; |
|
if (score > best.score) best = {score, lag, gain: xy / xx, correlation: yy ? xy / Math.sqrt(xx * yy) : 0}; |
|
} |
|
return best; |
|
} |
|
const cases = [ |
|
{aec: false, reference: true, echoGain: 0, onset: .5}, |
|
{aec: true, reference: false, echoGain: 0, onset: .5}, |
|
{aec: true, reference: true, echoGain: 0, onset: .5}, |
|
{aec: true, reference: true, echoGain: 0, onset: 3.5}, |
|
{aec: true, reference: true, echoGain: .3, onset: .5}, |
|
{aec: true, reference: true, echoGain: .3, onset: 3.5}, |
|
]; |
|
const results = []; |
|
for (const scenario of cases) { |
|
const reference = Buffer.alloc(RATE * 10 * 2), mic = Buffer.alloc(reference.length); |
|
for (let i = 0; i < reference.length; i += 2) reference.writeInt16LE(bot.readInt16LE(i % bot.length), i); |
|
caller.copy(mic, scenario.onset * RATE * 2); |
|
for (let i = 800; i < mic.length / 2; i++) { |
|
const sample = mic.readInt16LE(i * 2) + scenario.echoGain * reference.readInt16LE((i - 800) * 2); |
|
mic.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(sample))), i * 2); |
|
} |
|
const created = request('newApm', {echoCancellerEnabled: scenario.aec, gainControllerEnabled: false, highPassFilterEnabled: false, noiseSuppressionEnabled: false}); |
|
const handle = new FfiHandle(created.apm.handle.id), outputs = []; |
|
try { |
|
request('apmSetStreamDelay', {apmHandle: handle.handle, delayMs: 100}); |
|
for (let offset = 0; offset < mic.length; offset += 160) { |
|
if (scenario.reference) frame('apmProcessReverseStream', handle.handle, reference.subarray(offset, offset + 160)); |
|
outputs.push(frame('apmProcessStream', handle.handle, mic.subarray(offset, offset + 160))); |
|
} |
|
} finally {handle.dispose();} |
|
const output = Buffer.concat(outputs); |
|
if (output.length !== mic.length) throw new Error('Output length changed'); |
|
results.push({...scenario, ...speechQuality(output, scenario.onset), |
|
speechWindowEnergyDb: 10 * Math.log10(energy(output, scenario.onset * RATE, RATE) / energy(mic, scenario.onset * RATE, RATE)), |
|
echoResidualDb: scenario.echoGain ? 10 * Math.log10(energy(output, 8 * RATE, 2 * RATE) / energy(mic, 8 * RATE, 2 * RATE)) : null}); |
|
} |
|
fs.writeFileSync(path.join(__dirname, `results-${version}.json`), JSON.stringify(results, null, 2) + '\n'); |
|
console.log(JSON.stringify(results, null, 2)); |
|
process.exit(0); |