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