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

+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call transcripts and summaries (spec 10 §3). With ai.callTranscripts on, sp-sub-record also
// writes <call>-in.wav (what the caller said) and <call>-out.wav (what they heard), so who said
// what needs no speaker detection. A job writes each side out, merges them by time, asks ai.llm
// for a summary (ai.callSummaries), and deletes the two side files: the mixed recording stays.
import { access, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { and, asc, eq, gt, isNull, lt, sql } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { recordings } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
import { AiError, chat, transcribeSegments } from './client.ts';
export type Line = { side: 'caller' | 'other'; at: number; text: string };
/** The two side files next to a recording (relative, like recordings.file_path). */
export const sideFiles = (filePath: string) => {
const base = filePath.replace(/\.wav$/, '');
return [`${base}-in.wav`, `${base}-out.wav`] as const;
};
/**
* Where someone speaks in a 16-bit mono WAV (MixMonitor's: 44-byte header), in seconds: 20 ms
* frames louder than the line's noise, joined across pauses under `gap` s. Whisper's own timings
* drift across the long silences one side of a call has while the other talks, so each stretch is
* transcribed on its own, from its known start.
*/
export function speechStretches(wav: Buffer, gap = 0.6): { from: number; to: number }[] {
const rate = wav.readUInt32LE(24) || 8000;
const frame = Math.round(rate / 50) * 2; // bytes in 20 ms
const levels: number[] = [];
for (let i = 44; i + frame <= wav.length; i += frame) {
let sum = 0;
for (let j = i; j < i + frame; j += 2) sum += Math.abs(wav.readInt16LE(j));
levels.push(sum / (frame / 2));
}
// ponytail: a fixed share of the loudest speech, with a floor for silent lines; tune if a noisy
// line splits badly.
const loud = [...levels].sort((a, b) => a - b)[Math.floor(levels.length * 0.95)] ?? 0;
const threshold = Math.max(120, loud / 10);
const out: { from: number; to: number }[] = [];
levels.forEach((l, i) => {
if (l < threshold) return;
const at = i * 0.02;
const last = out.at(-1);
if (last && at - last.to < gap) last.to = at + 0.02;
else out.push({ from: at, to: at + 0.02 });
});
const end = levels.length * 0.02;
return out
.filter((s) => s.to - s.from >= 0.2) // a click, not a word
.map((s) => ({ from: Math.max(0, s.from - 0.2), to: Math.min(end, s.to + 0.2) }));
}
/** Seconds `from`–`to` of a WAV as a WAV of its own. */
export function wavSlice(wav: Buffer, from: number, to: number): Buffer {
const bytesPerSec = wav.readUInt32LE(28) || 16000;
const at = (s: number) => 44 + Math.min(wav.length - 44, Math.round(s * bytesPerSec) & ~1);
const data = wav.subarray(at(from), at(to));
const head = Buffer.from(wav.subarray(0, 44));
head.writeUInt32LE(36 + data.length, 4);
head.writeUInt32LE(data.length, 40);
return Buffer.concat([head, data]);
}
/** One side of a call, stretch by stretch, each line at its time in the whole call. */
async function transcribeSide(stt: Parameters<typeof transcribeSegments>[0], wav: Buffer) {
const lines: Omit<Line, 'side'>[] = [];
for (const s of speechStretches(wav))
for (const seg of await transcribeSegments(stt, wavSlice(wav, s.from, s.to)))
lines.push({ at: Math.round((s.from + seg.at) * 10) / 10, text: seg.text });
return lines;
}
/** Both sides in time order (the files start together, so their timings line up). */
export const mergeSides = (caller: Omit<Line, 'side'>[], other: Omit<Line, 'side'>[]): Line[] =>
[
...caller.map((l) => ({ side: 'caller' as const, ...l })),
...other.map((l) => ({ side: 'other' as const, ...l })),
].sort((a, b) => a.at - b.at || (a.side === 'caller' ? -1 : 1));
/** "Caller: …" lines, cut to fit a small model's context. */
export function transcriptText(lines: Line[], max = 12_000): string {
const text = lines
.map((l) => `${l.side === 'caller' ? 'Caller' : 'Other side'}: ${l.text}`)
.join('\n');
// ponytail: a long call is cut, not chunked; summarize in parts if calls this long matter.
return text.length > max ? `${text.slice(0, max)}\n[the rest of the call is cut]` : text;
}
const SUMMARY_PROMPT = `You summarize business phone calls from their transcript. Write in the \
language of the call, in plain text, no markdown headings:
- 1 to 3 sentences on what the call was about and what was agreed;
- then a line "Action items:" and one "- " line per item (who does what), or "Action items: none".
Use only what the transcript says. The transcript is automatic and may have mistakes.`;
/** Recordings the job still has to do: recent, finished a moment ago, not tried yet. */
const pending = (db: Db) =>
db
.select({ id: recordings.id, filePath: recordings.filePath })
.from(recordings)
.where(
and(
isNull(recordings.aiDoneAt),
gt(recordings.startedAt, sql`now() - interval '2 days'`),
lt(recordings.createdAt, sql`now() - interval '20 seconds'`), // files closed
),
)
.orderBy(asc(recordings.startedAt))
.limit(20);
const exists = (f: string) =>
access(f).then(
() => true,
() => false,
);
/** One run: every pending recording, one at a time (the GPU is shared). */
export async function transcribeCalls(d: {
db: Db;
dir: string;
log: { error(o: object, m: string): void };
}): Promise<number> {
const stt = await getSetting(d.db, 'ai.stt');
if (!stt || !(await getSetting(d.db, 'ai.callTranscripts'))) return 0;
const llm = (await getSetting(d.db, 'ai.callSummaries'))
? await getSetting(d.db, 'ai.llm')
: null;
let done = 0;
for (const r of await pending(d.db)) {
const files = sideFiles(r.filePath).map((f) => join(d.dir, f));
const finish = (set: Partial<typeof recordings.$inferInsert>) =>
d.db
.update(recordings)
.set({ aiDoneAt: new Date(), ...set })
.where(eq(recordings.id, r.id));
if (!(await exists(files[0]!)) || !(await exists(files[1]!))) {
await finish({ aiError: 'Recorded before call transcripts were on' });
continue;
}
let lines: Line[];
try {
const [caller, other] = [
await transcribeSide(stt, await readFile(files[0]!)),
await transcribeSide(stt, await readFile(files[1]!)),
];
lines = mergeSides(caller, other);
} catch (err) {
// The AI stack being down or busy: try again on the next run. Anything else: give up.
if (err instanceof AiError && /Can't reach|didn't answer/.test(err.message)) {
d.log.error({ err, recording: r.id }, 'call transcript: speech to text unreachable');
return done;
}
await finish({ aiError: err instanceof Error ? err.message : String(err) });
continue;
}
let summary: string | null = null;
let aiError: string | null = null;
if (llm && lines.length)
try {
summary = await chat(
llm,
[
{ role: 'system', content: SUMMARY_PROMPT },
{ role: 'user', content: transcriptText(lines) },
],
{ maxTokens: 2000, timeoutMs: 300_000 },
);
} catch (err) {
aiError = `Summary: ${err instanceof Error ? err.message : String(err)}`;
}
await finish({ transcript: lines, summary, aiError });
for (const f of files) await rm(f, { force: true });
done++;
}
return done;
}