Skip to content
anecho.ai
Guide · architecture

Your VAD and your transcriber want different audio.

Most voice stacks apply one filter to one track and hand the result to everything downstream. That is a routing decision disguised as a default, and it is usually the wrong one. Here is the alternative, and how to wire it.

Anecho in a voice-agent pipeline, with a split outputInbound audio enters Anecho. Two outputs leave it. The enhanced channel feeds Onset for turn-taking and barge-in. The raw channel feeds your speech-to-text engine, then your language model, then text-to-speech, and back to the caller. Nyquist scores call audio quality alongside both.INBOUNDSIP · WebRTCAnechoClearline · ChambersplitPipeline: trueCH2 ENHANCEDCH1 RAWOnsetVAD · endpoint · barge-inYour STTunprocessed by defaultYour LLMYour TTSto callerinterrupt / endpointNyquistcall-quality score → logs
One capture, two outputs. Turn-taking gets the clean channel because a suppressor removes exactly the background speech that false-triggers barge-in. Transcription gets the unprocessed channel because the same suppressor removes acoustic detail the recogniser was trained on. You can override either route per session — but this is the default, and defaults are the argument.

This is not a hunch. Every engine below raised word error rate on our test set and several of them cut the voice-activity false-alarm rate by twenty to thirty points in the conditions that break barge-in. Same audio, two consumers, opposite verdicts.

Split pipeline · measuredSame audio, two consumers, opposite verdicts
Effect of enhancement on transcription versus on voice activity detection, per engine.
EngineTranscriptionTurn-taking (Onset)
WER raw → enhΔWERIns raw → enhF1 raw → enhFA Babble 5 dBFA Competing 5 dB
ai-coustics Quail L14.815.6+0.791011260.9500.94598965858
ai-coustics Quail VF 2.2 L14.818.1+3.331011610.9500.94598755837
Resample-only 16→8→16 (control)14.818.6+3.861011730.9500.94898975855
GTCRN14.820.3+5.501012100.9500.94798925857
FastEnhancer-L14.820.4+5.671011590.9500.94298685839
FastEnhancer-S14.820.6+5.881011590.9500.94598855856
FastEnhancer-B14.821.1+6.371011590.9500.94498875856
FastEnhancer-M14.822.4+7.631012170.9500.94198755848
FastEnhancer-T14.822.5+7.691011450.9500.94198895857
DeepFilterNet3 (96-frame warm-up)14.831.6+16.871013680.9500.91498935857
DeepFilterNet314.835.2+20.411013800.9500.90198925857
Clearline (8 kHz, resampled)14.839.6+24.881014100.9500.83598825851

Every engine we tested raised word error rate. Several of them cut the VAD false-alarm rate by twenty to thirty points in the conditions that actually break turn-taking. That is not a contradiction — it is two different consumers with two different requirements, and it is why the SDK emits two channels instead of picking a winner on your behalf.

Raw → your transcriberEnhanced → Onset

Who wants what
Default channel routing per consumer
ConsumerDefault channelReasoning
Onset · VAD and endpointingEnhancedEndpointers trigger on energy and spectral change. Background speech and broadband noise produce false starts, clipped turns and barge-in that fires at the wrong person.
Barge-in / interrupt detectionEnhancedThe failure you are avoiding is a second talker in the room stopping the agent mid-sentence. Suppressing that talker is exactly the job.
Speech-to-textRaw, by defaultRecognisers are trained on noisy audio and have their own robustness. A suppressor removes acoustic detail the acoustic model was relying on, and the errors show up as deletions of short unstressed words.
Recording / complianceRawProcessed audio is not evidence of what was said in the room. Archive the capture, not the interpretation.
Human listening / QA reviewEnhancedThe one case where sounding better is the requirement.
Wiring it
node · @anecho/sdk
const session = await hear.createSession({
  model: "chamber-s",
  sampleRate: 16000,
  splitPipeline: true,
});

for await (const block of mic) {
  const frame = session.process(block);

  // Clean audio drives the conversation's timing…
  turnDetector.push(frame.enhanced);
  if (frame.speech && agent.isSpeaking) agent.interrupt();

  // …and the original drives what the words were.
  stt.push(frame.raw);
}
When to override the default

The default is an argument, not a law. Send enhanced audio to your transcriber when your own measurements say it helps. On our test set that was almost nowhere, and on band-limited speech nothing produced a meaningful gain in either direction. But that is one recogniser on one corpus. It is a measurement, not a rule, and it is different for every stack.

per-consumer override
const session = await hear.createSession({
  model: "chamber-s",
  splitPipeline: true,
  route: {
    stt: "enhanced",        // you measured it; it helps on your data
    turnTaking: "enhanced",
    recording: "raw",
  },
});

Before you flip it, run the same comparison we run: the comparator shows the shape of the effect, and Null Test shows it across the full matrix.

Cost of the split
  • One extra buffer per session for the delay line. Memory is a few kilobytes; there is no additional algorithmic latency, because the enhanced path already paid it.
  • Two consumers to feed instead of one. If your orchestrator assumes a single audio track, the plugins handle the fork for you.
  • One more thing to measure. That is the point — the routing is now a decision you can put a number on instead of a default you inherited.