VAD Lab v2

Metodologia de avaliação do VAD

Documentação de auditoria: o que o score mede, por que mede assim, e como cada número é calculado. Parte I é para decisão de negócio; Parte II é o detalhamento técnico completo.

Política: priority_gated v1 · Projeto: vad_interval_project_v2 · Última revisão: 2026-07-16

Parte I — Negócio: O problema · Os 6 erros e a ordem de gravidade · As 3 camadas da decisão · Por que gates · Quanto confiar nos números
Parte II — Técnica: Pipeline · Ground truth · Tolerâncias · Captura · Categorias de severidade · O score · Gates · Latência de onset · Agregação · Fingerprint · Trace → replay · Não-determinismo · Artefatos · Limitações

Parte I — Visão de negócio

1. O problema que o VAD resolve — e o que está em jogo

O agente de voz atende ligações telefônicas. O áudio nasce em G729 a 8 kHz e é convertido para G711 a 16 kHz no SBC antes de chegar ao LiveKit — uma cadeia fixa, que não podemos alterar. Dentro do LiveKit, o VAD (detector de atividade de voz) toma três decisões que definem a experiência da ligação:

  1. Cortar o áudio enviado ao STT (whisper-1 em batch): o whisper só transcreve o que o VAD entregar. Áudio perdido = palavras do cliente que nunca existiram para o sistema.
  2. Barge-in: quando o cliente fala por cima do agente, é o VAD que interrompe a fala do robô. Disparo lento = robô "atropela" o cliente; disparo falso = robô se cala sem motivo.
  3. Ritmo do turno: o fim de fala detectado pelo VAD inicia a resposta do agente. Segmentos emendados = respostas atrasadas; fala fragmentada = respostas no meio da frase.
Por que não usar o Silero "de fábrica"
O áudio é banda-estreita disfarçada: medimos 0% de energia acima de 4 kHz em todos os arquivos. Metade da banda que o Silero 16 kHz espera está vazia, o que deprime as probabilidades do modelo — os thresholds ótimos aqui (~0.16) são um terço do default (0.5). Toda a avaliação existe para encontrar e defender essas escolhas com dados do nosso tráfego, não benchmarks genéricos.

2. Os 6 tipos de erro e a ordem de gravidade

Todo erro do VAD cai em uma de seis categorias. A ordem abaixo é uma decisão de negócio (o que dói mais na operação), e é a espinha dorsal de toda a metodologia:

#ErroO que acontece na ligaçãoPeso
1Perder fala principal O cliente falou e o sistema não ouviu. O whisper nunca vê o áudio; não há transcrição, não há correção possível. O agente responde como se o cliente estivesse em silêncio. Irrecuperável. eliminatório (gate)
2Segmento órfão (ativar no nada) O VAD "ouviu" fala onde não há nenhuma (nem de fundo). Um chunk de ruído/silêncio vai ao whisper, que é conhecido por alucinar texto plausível nesses casos — o agente responde a algo que ninguém disse. Também dispara barge-in falso. 0.80
3Ponte entre falas (emendar por cima de silêncio longo) Duas falas separadas viram um segmento só. A resposta do agente atrasa (espera o segmento fechar) e turnos distintos do cliente se misturam numa transcrição só. Grave entre duas falas principais; menos grave quando envolve fala de fundo (ver #5). 0.60
4Spill (sobra fora da margem perdoada) O segmento invade áudio não-rotulado além da tolerância — ruído/silêncio anexado à fala real. Infla o payload do STT e aumenta o risco de lixo na transcrição. 0.40
5Fala de fundo (e pontes que a envolvem) Vozes de terceiros (TV, pessoas ao lado) entram no payload. O whisper transcreve texto real que não é do cliente. Peso menor porque geralmente vem anexada à fala legítima — mas é o erro mais insidioso quando aparece sozinho (ver §II-5). 0.30 / 0.20
6Fragmentação Uma fala vira vários segmentos. Hoje é amortecida pelo min_endpointing_delay do LiveKit (o agente não responde imediatamente a cada fim de segmento), por isso é a menos grave. Se o endpointing mudar, esta posição deve ser revista. 0.08
Como ler os pesos
Todas as taxas de erro são normalizadas pelo mesmo denominador (duração total do que o VAD produziu). Isso dá aos pesos um significado físico: 1 segundo de órfão custa 4× mais que 1 segundo de fala de fundo (0.80 vs 0.20). A ordem dos pesos É a ordem de gravidade — não é um ajuste estatístico opaco.

3. As três camadas da decisão

Um único número nunca conta a história toda. A política priority_gated separa a decisão em três camadas, cada uma respondendo a uma pergunta diferente:

┌─ CAMADA 0 · GATES ────────────────────────────────────────────────┐ │ "Essa config é aceitável?" → critérios ELIMINATÓRIOS │ │ • falas perdidas ≤ 1 • macro core recall ≥ 0.95 │ │ • latência onset p90 ≤ 0.25s • pior áudio ≥ 0.50 │ │ reprovou → vai pro fim da fila, com os motivos expostos │ └───────────────────────────────────────────────────────────────────┘ ┌─ CAMADA 1 · RANKING ──────────────────────────────────────────────┐ │ "Entre as aceitáveis, qual é melhor?" → score escalar 0–1 │ │ captura² × penalidades multiplicativas na ordem de gravidade │ └───────────────────────────────────────────────────────────────────┘ ┌─ CAMADA 2 · AUDITORIA ────────────────────────────────────────────┐ │ "ONDE ela é boa/ruim?" → vetor de severidade + latência │ │ 6 taxas por config e por áudio; alimenta análise humana ou LLM │ └───────────────────────────────────────────────────────────────────┘

Analogia: uma licitação. Os gates são os requisitos de habilitação (quem não cumpre está fora, não importa o preço); o score é a nota técnica que ordena os habilitados; o vetor é o parecer detalhado que justifica a nota e orienta a próxima rodada.

4. Por que gates — o caso real que os motivou

Score escalar é uma taxa de câmbio entre erros, e taxa de câmbio sempre permite trocas. Na comparação empírica de 2026-07-15, a configuração campeã antiga (act 0.34) perdia 6 das 111 falas do dataset — clientes literalmente ignorados — e ainda pontuava 0.837, a três centésimos do topo. A trava de nível de locutor perdia 5 e pontuava 0.839.

Num grid search automatizado, isso é uma armadilha: qualquer mecanismo que compre "limpeza" suficiente compensaria numericamente a perda de fala, e o otimizador escolheria um VAD que ignora 5% dos clientes. Ajustar pesos não resolve o dilema (peso alto demais → o score vira ruído; baixo demais → a troca continua permitida). A perda de fala não é mais cara — é inaceitável. Coisas inaceitáveis são restrições, não preços. Daí os gates.

Efeito prático
No grid da trava de locutor (delta 12/20 dB), as 4 configs foram reprovadas pelos gates (5–8 falas perdidas; uma delas também por latência p90 = 0,27 s) — exatamente o que o escalar sozinho escondia. Os limiares dos gates vêm do requisito de produto (quantas perdas por meia hora de áudio são toleráveis? que latência de interrupção o UX aceita?) e são configuráveis por flag.

5. Quanto confiar nos números

Parte II — Detalhamento técnico

1. Pipeline de avaliação

WAV 16 kHz (banda-estreita G729→G711, lido de /home/ubuntu/livekit_audios) │ frames de 20 ms, como produção ▼ oracle.VAD2 (Silero + RNNoise + APM-HPF, máquina de estados de histerese) │ eventos START_OF_SPEECH (latência) e END_OF_SPEECH (segmentos) ▼ segmentos preditos — dois recortes por segmento: • vad_decision (default): [fim−silêncio−fala, fim−silêncio] → mede o DETECTOR • stt_emitted: chunk real emitido c/ prefix padding → audita o PAYLOAD ▼ métricas por áudio (vadlab/metrics.py) → score + gates (vadlab/scoring.py) ▼ summary.json · per_audio.json/csv · segments.jsonl (com fingerprint)

O detector roda exatamente como em produção (mesma classe, mesmos frames, via livekit-agents VADStream). As tolerâncias de avaliação nunca são repassadas ao detector — são régua, não instrumento.

2. Ground truth: o labeling e suas convenções

ClasseO que marcaPapel na métrica
speechFala do locutor principal (o cliente), a fala audível — não o chunk com contextoBase de captura, pontes, fragmentação, perdas
background_speechVozes de terceiros (TV, ambiente)Intrusão de fundo (peso 0.20) e pontes mistas (0.30); nunca conta como captura
non_speech_eventMúsica de espera, URA, bipes, DTMFDiagnóstico: divide os órfãos em "ativou em evento" vs "ativou no nada" (não altera o score)

Convenções em vigor (deduzidas dos dados e agora normativas):

3. Tolerâncias de avaliação (o que é perdoado e por quê)

ParâmetroDefaultO que perdoaRacional
left_edge_tolerance_s0.100 Não cobrir até 100 ms do início de cada rótulo Imprecisão humana de borda + prefix padding cobre o contexto no payload real
right_edge_tolerance_s0.100 Não cobrir até 100 ms do fim de cada rótulo Idem; caudas de fonemas são ambíguas até para humanos
outer_edge_tolerance_s0.150 Sobrar até 150 ms além de cada rótulo, de cada lado Hangover natural do detector; não é spill
bridge_grace_s0.300 Até 300 ms de silêncio interno entre duas falas no mesmo segmento Abaixo da convenção de labeling (0,8 s); só o excesso vira ponte penalizada

Núcleo mensurável: ao aplicar as tolerâncias de borda num rótulo, sobra o "núcleo" que o VAD é obrigado a cobrir. Para rótulos curtíssimos, um piso interno de 32 ms garante que sempre exista núcleo (senão um rótulo de 150 ms seria 100% perdoável). Rótulo de 2 s com l=r=0,1 → núcleo de 1,8 s.

4. Captura (o lado "recall" do score)

capture = ( 0.70 · macro_core_recall + 0.30 · scored_duration_recall )² macro_core_recall = média, POR RÓTULO, da fração do núcleo coberta (um "sim" de 0,3 s pesa igual a um monólogo de 49 s) scored_duration_recall = Σ duração de núcleo coberta / Σ duração de núcleo (a visão por segundos, micro)

5. As categorias de severidade — definições exatas

Cada segmento predito é decomposto contra os rótulos. As definições são mutuamente exclusivas (sem dupla contagem) e todas as taxas usam o mesmo denominador: duração predita total.

Órfão (gravidade 2)

órfão ⇔ overlap(segmento, rótulos_fala ± outer_tol) = 0 E não toca fundo penalizável orphan_rate = max( nº órfãos / nº segmentos , duração órfã / duração predita )

O max garante que nem muitos órfãos curtinhos (count) nem um órfão longo (duração) escapem. O overlap com rótulos non_speech_event é reportado à parte (orphan_on_labeled_event_s) para separar "ativou na música de espera" de "ativou no nada".

Ponte primária (gravidade 3)

para cada segmento que toca ≥ 2 rótulos de fala: para cada par consecutivo de rótulos tocados: gap = [fim_rótulo_esq , início_rótulo_dir] ∩ segmento ponte = gap encolhido em bridge_grace/2 de cada lado (se sobrar > 0) primary_bridge_rate = Σ duração de ponte / duração predita

Ponte mista (gravidade 5a)

Mesma construção, mas para pares de vizinhos onde pelo menos um é fala de fundo (fala↔fundo ou fundo↔fundo). Menos grave que emendar duas falas do cliente (0.30 vs 0.60) — mistura turnos, mas não turnos legítimos entre si.

Spill exclusivo (gravidade 4)

spill_bruto = segmento − (rótulos_fala ± outer_tol) − rótulos_fundo spill_exclusivo = spill_bruto − duração_órfã − spill_dentro_de_pontes

A subtração evita contar o mesmo segundo duas vezes: um órfão é 100% spill por definição, e o silêncio de uma ponte também é spill — cada segundo é punido apenas na sua categoria mais específica (que é também a mais grave).

Intrusão de fundo (gravidade 5b)

Overlap do segmento com rótulos de fundo fora das zonas de tolerância externa da fala principal (fundo colado na fala do cliente é perdoado — inseparável na prática).

Fragmentação (gravidade 6)

para cada rótulo de fala coberto por N ≥ 2 trechos preditos disjuntos: excesso += N − 1 fragmentation_excess_rate = Σ excessos / nº de rótulos de fala

6. O score escalar (Camada 1)

priority_score = capture²ᵃᵖˡⁱᶜᵃᵈᵒ × (1 − 0.80·orphan_rate) × (1 − 0.60·primary_bridge_rate) × (1 − 0.30·mixed_bridge_rate) × (1 − 0.40·exclusive_spill_rate) × (1 − 0.20·background_intrusion_rate) × (1 − 0.08·fragmentation_excess_rate) — clampado em [0, 1]

7. Gates (Camada 0) — especificação

GateDefaultMétrica exataFlag
Falas perdidas≤ 1 nº de rótulos de fala com overlap ZERO com qualquer segmento (dataset inteiro) --gate-max-missed
Macro core recall≥ 0.95 média por rótulo da cobertura do núcleo (§4) — pega mutilação parcial sistemática --gate-min-macro-core
Latência de onset p90≤ 0.25 s p90 do atraso do START_OF_SPEECH vs início do rótulo (§8) --gate-max-onset-p90
Pior áudio≥ 0.50 menor priority_score per-audio — anti-catástrofe localizada que a agregação global diluiria --gate-min-worst-audio

Semântica: reprovar não esconde nada — o score e os valores continuam calculados e visíveis (permite ver quão longe da régua a config ficou). No ranking do grid, ordena-se por (passou_gates, score). Valor -1 desliga um gate. A definição completa dos gates usados fica gravada no score_definition de cada artefato.

Por que "≤ 1" e não zero: existe um caso limítrofe conhecido (fala curta e baixa) que oscila com o ruído de pipeline (§12); gate em 0 reprovaria por azar, não por mérito. O limiar é requisito de produto: ~1 perda tolerada por ~30 min de áudio.

8. Latência de onset (barge-in)

para cada rótulo de fala principal: latência = (timestamp do 1º START_OF_SPEECH dentro de [início−0.25s, fim+0.25s]) − início_rótulo reportado: mediana, p90, contagem (por áudio e global)

É o tempo entre o cliente começar a falar e o sistema saber que ele falou — o que o barge-in sente. Não é derivável da geometria dos segmentos (um chunk com 1 s de padding pode cobrir o rótulo perfeitamente tendo disparado tarde). O avaliador v1 descartava esses eventos; aqui são medidos sempre. Referência atual (config ultra): mediana 0,127 s, p90 0,176 s. Usa-se p90 no gate porque a cauda é o que destrói a experiência — a mediana é ~130 ms em quase qualquer config.

9. Agregação por áudio → global

10. Fingerprint do dataset

fingerprint = sha256( JSON canônico de: [audio_id, filename, duração, status, intervalos speech/background/non_speech_event arredondados a ms] )[:16]

Gravado em todo summary.json, em todo manifest de trace, e verificado pela UI (⚠ quando um run foi medido contra labels que não são os atuais). Motivação histórica: no projeto v1, runs antigos re-pontuados contra um dataset que tinha crescido (19→20 áudios, 70→111 rótulos) produziram scores absurdos sem nenhum aviso. Regra: scores só se comparam com fingerprints iguais. Mudou rótulo → re-roda (o replay torna isso barato).

11. Trace → replay: como o grid fica 2400× mais barato

Insight: a parte cara do VAD (preprocessamento + inferência do Silero) não depende de nenhum parâmetro da máquina de estados. Thresholds, min_speech/min_silence, padding e os mecanismos do VAD2 consomem apenas três escalares por janela de 32 ms:

trace por áudio (npz): p_raw[i] — probabilidade crua do Silero na janela i (antes da suavização) dbfs[i] — RMS dBFS da própria janela de inferência (pós-preproc) rnn[i] — EMA da prob. de fala do RNNoise (NaN se desligado)

trace roda o pipeline real uma vez por configuração de preprocessamento (~2 min) e cacheia. replay reexecuta a máquina de estados inteira sobre o cache — incluindo o ExpFilter (α=0.35), a histerese, o buffer de payload com prefix padding e os 4 mecanismos adaptativos do VAD2 — em ~60 ms para o dataset todo. Um grid de 88 configs roda em 4,9 s.

Prova de fidelidade
A réplica da FSM é validada por evaluate.py verify: no caminho determinístico (sem preprocessamento), streaming e replay produzem segmentos bit-idênticos — verificado inclusive com a trava de locutor e re-ancoragem ligadas. A acumulação de tempo do replay usa as mesmas operações de ponto flutuante do streaming (+= 0.032, nunca i × 0.032) para reproduzir até o drift numérico das comparações de duração.

Limitações declaradas do replay: (a) o alpha da EMA do RNNoise é fixado na captura do trace (o min_prob da fusão pode variar; o alpha não); (b) mudar o preprocessamento exige novo trace.

12. Não-determinismo do pipeline (descoberta de 2026-07-16)

Ao provar a fidelidade do replay, isolamos por camada uma divergência conhecida do v1 (o falso "warmup" de ±0.002): o rtc.AudioResampler do LiveKit aplica dither aleatório não-semeado (Δ≤4 LSB por amostra, medido comparando duas execuções sobre entrada idêntica). O RNNoise amplifica para Δ≈950 LSB, e o Silero converte isso em até Δ0.05 de probabilidade em janelas marginais. ONNX puro e APM-HPF são determinísticos; o dither é o único ofensor.

ConsequênciaPolítica adotada
Dois runs idênticos diferem ±0.005 no scoreDiferenças < 0.005 = empate técnico
Blips de 0,1–0,3 s aparecem/somem entre runs (órfãos oscilam 2–7) Órfão usa max(count, duração); min_speech maior é eixo de grid anti-blip
Streaming ≠ replay bit a bit (com preproc) verify em 2 níveis: bit-exato sem preproc; match estrutural com tolerância com preproc (blips não-pareados < 0,35 s = dither perdoado)
Comparações entre configs contaminadas por sorteio Ranking sempre no replay (mesmo trace = mesmo sorteio para todas) ; número oficial no streaming

13. Artefatos e trilha de auditoria

Cada run salvo em evals/<nome>/ contém tudo o que é preciso para re-auditar sem re-executar:

ArquivoConteúdo
summary.json KPIs globais, resultado dos gates (com valores e motivos), vetor de severidade, score_definition completo (pesos, gates, tolerâncias, fingerprint), config integral do detector, resumo do dataset, timestamp
per_audio.json / .csv Todas as métricas por áudio — incluindo listas de falas perdidas e órfãos com timestamps (campos declarados; nada se perde na serialização)
segments.jsonl Cada segmento predito com os DOIS recortes (decisão e payload emitido), timestamps de evento e durações — permite re-pontuar com outra régua no futuro
grid_results.json grids Vetor de severidade + gates + score de cada config varrida, eixos usados, config base

Fluxo de auditoria de um número: summary.json → score_definition (que régua?) → severity_vector (de onde veio o desconto?) → per_audio (em que áudio?) → /eval (ver e ouvir o trecho exato — os problemas viram chips clicáveis que dão seek no waveform). O caminho inverso (do áudio ao score) também fecha, porque o escalar é reconstruível à mão a partir do vetor (§6).

14. Limitações conhecidas e evolução prevista

15. Glossário rápido

Núcleo (core)Parte do rótulo que sobra após o perdão de borda; o que o VAD é obrigado a cobrir
ÓrfãoSegmento predito sem nenhuma sobreposição com fala (nem margens, nem fundo)
PonteSilêncio entre dois rótulos mantido dentro de um mesmo segmento, além da graça
SpillDuração predita fora de rótulos e margens (excluindo órfãos e pontes, já contados)
vad_decision / stt_emittedRecorte "o que o detector decidiu" vs "o que foi enviado ao STT (com padding)"
Trace / replayCache dos escalares por janela / reexecução offline da máquina de estados
FingerprintHash dos rótulos; define o universo em que scores são comparáveis
GateCritério eliminatório avaliado antes do ranking

Fontes: vadlab/metrics.py (definições), vadlab/scoring.py (pesos e gates), vadlab/replay.py (FSM), RELATORIO_V2.md (validações e descobertas), RELATORIO_SCORING_POLITICAS.md no projeto v1 (comparação empírica que originou esta política).