Sub-fase B do modulo de IA: novo servico apps/ai-worker (poll + FOR UPDATE SKIP LOCKED) processa AIJob de TRANSCRIPTION/ANALYSIS disparados automaticamente apos uma gravacao ficar disponivel, respeitando a cascata de privacidade Tenant>Queue>Campaign e o entitlement do Plan. Transcricao separa o WAV estereo em 2 canais (parser proprio, sem ffmpeg) e transcreve cada perna independente; analise sempre redige dados sensiveis antes de sair pro provider e valida o resultado contra o schema antes de persistir. CRUD de AIPromptTemplate/AIPromptVersion em apps/api. Testado ponta a ponta contra o worker real em Docker e Postgres real com RLS (cascata de privacidade em 3 cenarios, WAV sintetico real no object storage, claim/retry/dead-letter reais) — chamada de rede contra OpenAI/Anthropic continua nunca exercitada (mesma restricao de rede desde o Provider Layer). Detalhes em docs/AI_PIPELINE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
/**
|
|
* Separação de canais de um WAV estéreo PCM (agente.md secao 111: "sempre
|
|
* que possível utilizar canais estéreo pra identificar Agent/Customer...
|
|
* não confiar cegamente em diarização quando a direção do áudio permite
|
|
* identificação melhor"). Em vez de mandar o áudio estéreo misturado pro
|
|
* provider de transcrição (que faria uma diarização probabilística), cada
|
|
* canal vira um WAV mono separado, transcrito independentemente — o
|
|
* speaker de cada segmento fica 100% determinístico pelo canal de origem,
|
|
* não por inferência.
|
|
*
|
|
* Parser mínimo, não uma lib de áudio genérica: só entende exatamente o
|
|
* formato que `record_session`/RECORD_STEREO do FreeSWITCH produz (RIFF/
|
|
* WAVE, PCM linear, 16-bit) — o suficiente pro nosso pipeline, sem trazer
|
|
* uma dependência de processamento de áudio (ffmpeg) só pra isso.
|
|
*
|
|
* Convenção assumida (não verificada contra áudio real distinguível nesta
|
|
* sessão — as gravações de teste eram silêncio de `null/dummy`, sem como
|
|
* confirmar auditivamente qual canal é qual): canal 0 = a perna em que
|
|
* `record_session` foi chamado (o member/"cliente" no fluxo do discador),
|
|
* canal 1 = a perna bridgeada (o agente). Revisar com uma chamada real
|
|
* antes de confiar no mapeamento em produção — ver docs/AI_PIPELINE.md.
|
|
*/
|
|
|
|
interface WavInfo {
|
|
numChannels: number;
|
|
sampleRate: number;
|
|
bitsPerSample: number;
|
|
dataOffset: number;
|
|
dataLength: number;
|
|
}
|
|
|
|
function parseWavHeader(buffer: Buffer): WavInfo {
|
|
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
|
|
throw new Error("Arquivo nao e' um WAV RIFF valido");
|
|
}
|
|
|
|
let offset = 12;
|
|
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | undefined;
|
|
let dataOffset: number | undefined;
|
|
let dataLength: number | undefined;
|
|
|
|
while (offset + 8 <= buffer.length) {
|
|
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
|
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
const bodyStart = offset + 8;
|
|
|
|
if (chunkId === "fmt ") {
|
|
fmt = {
|
|
numChannels: buffer.readUInt16LE(bodyStart + 2),
|
|
sampleRate: buffer.readUInt32LE(bodyStart + 4),
|
|
bitsPerSample: buffer.readUInt16LE(bodyStart + 14),
|
|
};
|
|
} else if (chunkId === "data") {
|
|
dataOffset = bodyStart;
|
|
dataLength = chunkSize;
|
|
}
|
|
|
|
offset = bodyStart + chunkSize + (chunkSize % 2); // chunks são alinhados em 2 bytes
|
|
}
|
|
|
|
if (!fmt || dataOffset === undefined || dataLength === undefined) {
|
|
throw new Error("WAV sem chunk fmt/data reconhecível");
|
|
}
|
|
if (fmt.bitsPerSample !== 16) {
|
|
throw new Error(`Só suporta PCM 16-bit (recebido ${fmt.bitsPerSample}-bit)`);
|
|
}
|
|
|
|
return { ...fmt, dataOffset, dataLength };
|
|
}
|
|
|
|
function buildMonoWav(samples: Buffer, sampleRate: number): Buffer {
|
|
const header = Buffer.alloc(44);
|
|
header.write("RIFF", 0, "ascii");
|
|
header.writeUInt32LE(36 + samples.length, 4);
|
|
header.write("WAVE", 8, "ascii");
|
|
header.write("fmt ", 12, "ascii");
|
|
header.writeUInt32LE(16, 16); // fmt chunk size
|
|
header.writeUInt16LE(1, 20); // PCM
|
|
header.writeUInt16LE(1, 22); // 1 canal
|
|
header.writeUInt32LE(sampleRate, 24);
|
|
header.writeUInt32LE(sampleRate * 2, 28); // byte rate (16-bit mono)
|
|
header.writeUInt16LE(2, 32); // block align
|
|
header.writeUInt16LE(16, 34); // bits per sample
|
|
header.write("data", 36, "ascii");
|
|
header.writeUInt32LE(samples.length, 40);
|
|
return Buffer.concat([header, samples]);
|
|
}
|
|
|
|
export interface StereoSplitResult {
|
|
channel0: Buffer;
|
|
channel1: Buffer;
|
|
sampleRate: number;
|
|
}
|
|
|
|
/** Recebe um WAV estéreo 16-bit e retorna dois buffers WAV mono (um por
|
|
* canal), prontos pra transcrever cada um separadamente. */
|
|
export function splitStereoWav(input: Buffer): StereoSplitResult {
|
|
const info = parseWavHeader(input);
|
|
if (info.numChannels !== 2) {
|
|
throw new Error(`Esperava WAV estéreo, recebeu ${info.numChannels} canal(is)`);
|
|
}
|
|
|
|
const frameCount = Math.floor(info.dataLength / 4); // 2 canais * 2 bytes
|
|
const left = Buffer.alloc(frameCount * 2);
|
|
const right = Buffer.alloc(frameCount * 2);
|
|
|
|
for (let i = 0; i < frameCount; i++) {
|
|
const frameOffset = info.dataOffset + i * 4;
|
|
left.writeInt16LE(input.readInt16LE(frameOffset), i * 2);
|
|
right.writeInt16LE(input.readInt16LE(frameOffset + 2), i * 2);
|
|
}
|
|
|
|
return {
|
|
channel0: buildMonoWav(left, info.sampleRate),
|
|
channel1: buildMonoWav(right, info.sampleRate),
|
|
sampleRate: info.sampleRate,
|
|
};
|
|
}
|