ai: call transcripts (each side recorded, merged by time) and summaries; search on Recordings
ci / check (push) Successful in 1m22s
ci / images (push) Successful in 27s

This commit is contained in:
iamdoubz committed 2026-10-21 19:10:02 -05:00
1 parent d9f9c6e444
commit 4d331fb70e
56 files changed
+5186 -37

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call transcripts (spec 10 §3): with ai.callTranscripts on, sp-sub-record writes the mixed
// recording plus <call>-in.wav (what the caller said) and <call>-out.wav (what they heard). A Local
// channel records itself through sp-sub-record and dials one that answers and stays silent; the
// other half plays digits as "the caller". So -in must have sound and -out (nearly) none.
// Usage: node --conditions=source apps/api/test/asterisk/rec-test.ts (ASTERISK_IMAGE)
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const AST = 'sp-test-rec';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const cli = (cmd: string) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const log = (...a: unknown[]) => console.log('[rec-test]', ...a);
function snapshot(): Snapshot {
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
settings['ai.callTranscripts'] = true;
settings['ai.stt'] = { url: 'http://127.0.0.1:8000/v1', model: 'whisper' };
return {
today: '2026-10-21',
settings,
dids: [],
trunks: [],
routes: [],
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
announcements: [],
recordingAnnouncement: null,
extensions: [],
mohClasses: [],
};
}
async function main() {
const dir = mkdtempSync(join(tmpdir(), 'sp-rec-'));
const conf = join(dir, 'conf');
mkdirSync(conf);
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
const problems: string[] = [];
try {
docker('rm', '-f', AST);
const run = docker(
'run',
'-d',
'--rm',
'--name',
AST,
'-e',
'AMI_SECRET=rec-test-secret',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
if (!run.ok) throw new Error(`asterisk: ${run.out}`);
for (
let i = 0;
i < 60 && !docker('exec', AST, 'asterisk', '-rx', 'core waitfullybooted').ok;
i++
)
await sleep(500);
log(
cli('dialplan show sp-sub-record')
.split('\n')
.filter((l) => l.includes('MixMonitor'))
.join(' | '),
);
for (const line of [
'dialplan add extension rec,1,Gosub,"sp-sub-record,s,1" into sp-rectest',
'dialplan add extension rec,2,Dial,"Local/play@sp-rectest/n" into sp-rectest',
'dialplan add extension play,1,Answer, into sp-rectest',
'dialplan add extension play,2,Wait,4 into sp-rectest',
])
cli(line);
cli('channel originate Local/rec@sp-rectest/n application Playback digits/1&digits/2&digits/3');
await sleep(6000);
const ls = docker(
'exec',
AST,
'sh',
'-c',
'find /var/spool/asterisk/monitor -name "*.wav" -exec stat -c "%s %n" {} \\;',
).out.trim();
log(ls.replaceAll('\n', ' | ') || 'no files');
const sized = ls
.split('\n')
.filter(Boolean)
.map((l) => {
const [size, name] = l.split(' ');
return { size: Number(size), name: name! };
});
const mixed = sized.find((f) => /\/\d+\.\d+\.wav$/.test(f.name));
if (!mixed) problems.push('no mixed recording');
else {
for (const side of ['-in.wav', '-out.wav']) {
const f = sized.find((x) => x.name === mixed.name.replace(/\.wav$/, side));
if (!f) problems.push(`no ${side} next to ${mixed.name}`);
else if (f.size < 8000) problems.push(`${side} has no audio (${f.size} bytes)`);
}
// Each side on its own, the right way round: the caller's file has the digits, the silent
// side's has nothing (a copy of the mix would have them too).
const loud = (side: string) => {
const wav = spawnSync('docker', ['exec', AST, 'cat', mixed.name.replace(/\.wav$/, side)])
.stdout as Buffer;
let sum = 0;
for (let i = 44; i + 1 < wav.length; i += 2) sum += Math.abs(wav.readInt16LE(i));
return Math.round(sum / ((wav.length - 44) / 2));
};
const [mix, caller, other] = [loud('.wav'), loud('-in.wav'), loud('-out.wav')];
log(`average level: mix ${mix}, -in ${caller}, -out ${other}`);
if (!(mix > 200 && caller > 200 && other < caller / 10))
problems.push(
`-in should be the caller (loud), -out the silent side: ${caller} / ${other}`,
);
}
} catch (e) {
problems.push((e as Error).message);
} finally {
docker('rm', '-f', AST);
rmSync(dir, { recursive: true, force: true });
}
if (problems.length) {
console.log('FAIL call recording sides');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log('ok call recording sides (mixed, -in and -out with audio)');
}
await main();