Files
B2BCall-dialer/packages/ai/src/wav-stereo-split.ts
Matheus 91c0448dd4 feat(ai): pipeline assincrono — transcricao, analise, prompts (fase 20)
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
2026-08-28 16:39:38 -03:00

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,
};
}