When a device listens and talks at the same time, the sound leaving the speaker feeds back into the microphone that is listening for input. Acoustic echo cancellation removes that output from the capture.
Whatever leaves the speaker travels back to the microphone a few milliseconds later, mixed in with whatever the person is actually saying. The microphone cannot tell the two apart, so everything downstream treats your own output as though someone in the room had said it.
In practice that means:
AEC is built into Decibri. It takes the audio going to the speaker and removes that sound from the microphone capture. Switching it on is one option on the microphone, plus one call each time a block of audio goes to the speaker.
Use it when playback and capture overlap in time and the sound is leaving through a loudspeaker. The test is simply whether the two are ever live at the same moment.
Common cases: an assistant you can interrupt mid-sentence, a conferencing client on laptop speakers, a speakerphone, a kiosk, or a robot that talks to you while still listening for your next instruction.
An agent you can interrupt is the clearest case. It is only pleasant to talk to if you can cut in while it is still speaking, and that means leaving the microphone open through its own reply. Without cancellation, the first thing it hears when it starts speaking is itself.
Capture is normalised to mono at the sample rate you asked for, and cancellation runs on that, ahead of every ACE conditioning stage. The speech detector reads the signal the moment cancellation has finished with it.
capture → echo cancellation → ACE conditioning
That ordering has two consequences.
vadScore and the speech and silence events describe the person in the room, not your own playback. Read the limits section before relying on this, because it reduces false triggering rather than eliminating it.
agc or limiter, they act on the already-cancelled signal. There is no ordering for you to configure.
Cancellation subtracts an estimate, and where the estimate is wrong the result can exceed the level of the original capture. With dtype: 'int16' the format clamps, so a sample arrives at full scale rather than wrapping. With float32 and no limiter, clamp in your own code or set limiter, which runs last and holds the output at its ceiling.
The simplest case: the audio is already prepared, and you are playing it through Decibri's own speaker. Feed each block to both the speaker and the canceller.
from decibri import File, Microphone, Speaker
RATE = 16000
# Whatever you are about to play. Any mono PCM at the capture rate works.
with File("speech.wav", sample_rate=RATE) as f:
audio = b"".join(f)
block_bytes = RATE // 10 * 2 # 100 ms of int16
with Microphone(sample_rate=RATE, aec="tau") as mic, Speaker(sample_rate=RATE) as speaker:
for i in range(0, len(audio), block_bytes):
block = audio[i : i + block_bytes]
speaker.write(block) # play it
mic.push_aec_reference(block) # and tell the canceller you played it
near = mic.read(timeout_ms=100) # microphone audio, echo removed
const { Microphone, Speaker, File } = require('decibri');
const RATE = 16000;
async function main() {
// Whatever you are about to play. Any mono PCM at the capture rate works.
const file = await File.open('speech.wav', { sampleRate: RATE });
const chunks = [];
for await (const chunk of file) chunks.push(chunk);
const audio = Buffer.concat(chunks);
const mic = new Microphone({ sampleRate: RATE, aec: 'tau' });
const speaker = new Speaker({ sampleRate: RATE });
mic.on('data', (chunk) => {
// chunk is the microphone audio with the speaker's echo removed
});
mic.on('error', (err) => console.error(err.message));
const BLOCK = (RATE / 10) * 2; // 100 ms of int16
for (let i = 0; i < audio.length; i += BLOCK) {
const block = audio.subarray(i, Math.min(i + BLOCK, audio.length));
speaker.write(block); // play it
mic.pushAecReference(block); // and tell the canceller you played it
await new Promise((resolve) => setTimeout(resolve, 100));
}
mic.stop();
speaker.stop();
}
main();
That is the whole integration. One option on the microphone, one call per block of playback.
This is the part to get right. Everything else on this page is defaults.
The reference is the audio that went out to the speaker: the same samples you handed to the output device, at the moment you handed them over.
It is not the source material as it stood before you processed it. It is not the file as it sat on disk. It is not what the microphone picked up. It is the output itself, as sent.
So if you change the audio on its way to the speaker, by adjusting the volume, resampling it, or mixing in a notification chime, hand over what came out of that step rather than what went into it. The canceller is searching your capture for this exact signal, and the closer it matches what actually left the speaker, the more it can remove.
The reference is always mono, and always in the same sample format as the microphone, int16 or float32 as your dtype says.
The reference is a continuous stream. Every moment of capture has a matching moment of playback, and while nothing is being played, that playback is silence.
You do not have to push that silence yourself. Decibri fills the gaps, so a caller that simply stops pushing between one utterance and the next has told the canceller that nothing played, which is true. The alignment survives the gap and carries on when you start pushing again.
Worth stating plainly, because the next rule depends on it: not pushing does not mean "nothing happened", it means "silence played".
Push blocks in the order they were played, with nothing left out. Decibri lays the reference down against the capture in the order it arrives, so a block you skip is not treated as a gap. It is treated as silence, and everything you push after it lands in the wrong place.
If an error path drops a block, or an async boundary reorders them, push what actually played in the order it actually played. If you truly cannot, pushing nothing is better than pushing something out of order.
Push in step with playback, block by block, rather than handing over a long buffer in advance. The reference is held in a queue that spans two seconds at the reference rate; a single push longer than that loses the excess, counted in referenceDropped.
This is the one that fails silently. Playback and capture rates commonly differ. Speech synthesis services typically return 24 kHz audio, while a capture feeding a speech recogniser usually runs at 16 kHz. A browser or conferencing stack will hand you 48 kHz.
Declare the reference rate and Decibri converts it for you:
Microphone(
sample_rate=16000,
aec=Aec(model="tau", reference_sample_rate=24000),
)
new Microphone({
sampleRate: 16000,
aec: { model: 'tau', referenceSampleRate: 24000 },
});
Then push the audio at its own rate, unconverted. Do not resample it yourself first; if you do, do not declare the rate, because the two together apply the conversion twice.
The check that catches this: with playback running, read the metrics. If delaySamples is null after a few seconds of far-end audio, the rate is the first thing to look at.
Decibri does not have to be the thing playing. A speech synthesis service, a conferencing track, a browser player, a game engine: any of them can do the playing and cancellation still works. What matters is that your code handles the samples on their way out. Wherever it hands them to the player is where you push, with the same buffer and the rate declared.
from decibri import Aec, File, Microphone, Speaker
CAPTURE_RATE = 16000
PLAYBACK_RATE = 24000 # whatever the player runs at
# Audio from somewhere else, at that source's own rate.
with File("speech.wav", sample_rate=PLAYBACK_RATE) as f:
audio = b"".join(f)
mic = Microphone(
sample_rate=CAPTURE_RATE,
aec=Aec(model="tau", reference_sample_rate=PLAYBACK_RATE),
)
player = Speaker(sample_rate=PLAYBACK_RATE)
block_bytes = PLAYBACK_RATE // 10 * 2 # 100 ms at the playback rate
with mic, player:
for i in range(0, len(audio), block_bytes):
block = audio[i : i + block_bytes]
player.write(block) # hand it to the player
mic.push_aec_reference(block) # and push the same bytes, unconverted
near = mic.read(timeout_ms=100)
const { Microphone, Speaker, File } = require('decibri');
const CAPTURE_RATE = 16000;
const PLAYBACK_RATE = 24000; // whatever the player runs at
async function main() {
// Audio from somewhere else, at that source's own rate.
const file = await File.open('speech.wav', { sampleRate: PLAYBACK_RATE });
const chunks = [];
for await (const chunk of file) chunks.push(chunk);
const audio = Buffer.concat(chunks);
const mic = new Microphone({
sampleRate: CAPTURE_RATE,
aec: { model: 'tau', referenceSampleRate: PLAYBACK_RATE },
});
const player = new Speaker({ sampleRate: PLAYBACK_RATE });
mic.on('data', (chunk) => {
// near-end audio, echo removed
});
mic.on('error', (err) => console.error(err.message));
const BLOCK = (PLAYBACK_RATE / 10) * 2; // 100 ms at the playback rate
for (let i = 0; i < audio.length; i += BLOCK) {
const block = audio.subarray(i, Math.min(i + BLOCK, audio.length));
player.write(block); // hand it to the player
mic.pushAecReference(block); // and push the same bytes, unconverted
await new Promise((resolve) => setTimeout(resolve, 100));
}
mic.stop();
player.stop();
}
main();
One thing this pattern does not cover: if the player converts the audio again before it reaches the speaker, push what you gave the player and accept that the reference is one step removed from what was actually played. It usually still works.
A push before capture is running is discarded silently. It is not counted as a drop, and no error is raised.
The reliable check is the metrics accessor: it returns null until capture is running, and an object once it is. In Python, the context manager and start() both return with capture running, so a push straight afterwards is accepted. In Node the stream starts when you begin consuming it, so push from inside your data handler or your playback loop, not immediately after the constructor returns.
aec accepts a model name for the defaults, or an object to tune it.
from decibri import Aec, Microphone
# Short form: the model with its defaults.
a = Microphone(sample_rate=16000, aec="tau")
# Object form: the same model, every option set to its default.
b = Microphone(
sample_rate=16000,
aec=Aec(
model="tau",
tail_ms=200,
suppression="conservative",
reference_sample_rate=16000,
),
)
const { Microphone } = require('decibri');
// Short form: the model with its defaults.
const a = new Microphone({ sampleRate: 16000, aec: 'tau' });
// Object form: the same model, every option set to its default.
const b = new Microphone({
sampleRate: 16000,
aec: {
model: 'tau',
tailMs: 200,
suppression: 'conservative',
referenceSampleRate: 16000,
},
});
'tau' is the model available today. It carries no model file and needs no download. Passing the short form and passing { model: 'tau' } are the same thing.
An unrecognised name is rejected when the microphone is constructed, with a message naming the accepted set. Leave this alone unless a future release adds another name.
How much echo delay spread the canceller can account for, in milliseconds. Accepted range 16 to 500, default 200.
Raise it when the room is large or reverberant, or when the audio path adds delay, for example Bluetooth output or a device with deep buffering. Lower it for a small enclosure with a short direct path. Longer settings ask more of the machine, so prefer the default unless you have a reason.
'conservative' (the default) attenuates the residue the linear stage leaves behind. 'off' delivers the linear output as it stands.
Leave it at 'conservative' for a normal capture. Choose 'off' when downstream processing expects an unmodified near-end signal and you would rather handle the residue yourself.
The rate of the audio you push, in Hz. Accepted range 1000 to 384000. Unset means the reference is already at the capture rate.
Set it whenever your playback rate differs from your capture rate. See the rate section above; this is the option that exists for it.
Echo cancellation requires sampleRate between 8000 and 48000, narrower than the range sampleRate otherwise accepts. A rate that is fine for a plain capture is rejected once echo cancellation is on.
Capture is mono, as it is for every decibri capture.
Echo cancellation is available on native capture only. The browser build does not take this option, because browser capture carries the platform's own echoCancellation constraint, which is on by default.
aecMetrics() in Node, aec_metrics() in Python. Both return null / None when the aec option is unset or capture is not running, and an object otherwise.
| Node | Python | Type | Meaning |
|---|---|---|---|
delaySamples |
delay_samples |
number or null | Active alignment between reference and capture. null while searching. |
erleDb |
erle_db |
number | Smoothed estimate of how much echo is being removed, in dB. |
doubleTalk |
double_talk |
boolean | Whether the near-end talker is believed active. Adaptation is held while true. |
referenceStarved |
reference_starved |
number | Near-end samples with no far-end sample available while aligned. |
acquisitionParked |
acquisition_parked |
number | Near-end samples processed while no alignment was active. |
referenceReanchors |
reference_reanchors |
number | Times the alignment was rebuilt after a capture discontinuity. |
referenceDropped |
reference_dropped |
number | Far-end samples discarded because a push exceeded the queue, at the reference rate. |
referenceSilence |
reference_silence |
number | Far-end samples supplied as silence because none were pushed, at the capture rate. |
Read the metrics while far-end audio is actually playing, and give it a few seconds. Then work down this list. The first match is your answer.
aec, and that capture has started. Any pushes you made in this state were discarded.delaySamples is a number. The canceller has found your echo and is working. If echo is still audible, that is the limits section, not a fault. Nothing below applies.delaySamples is null and referenceSilence is climbing at the same rate as acquisitionParked. No reference is reaching the canceller. Every sample it processed was silence it filled in for you. Either you are not calling the push method, or you are calling it before capture is running, or you are calling it on a different microphone instance than the one capturing.delaySamples is null and referenceDropped is climbing. You are pushing faster than capture consumes, or in blocks longer than the queue. Push smaller blocks, in step with playback, rather than handing over long buffers.delaySamples is null, referenceSilence is not climbing, and nothing is being dropped. The reference is arriving and is being consumed, but it does not line up with the echo. In order of likelihood: the rate is wrong and undeclared; the audio you are pushing is not the audio that played; or the far end is material the canceller cannot lock onto, which is covered in the limits.referenceStarved is climbing. You have run further ahead of your own capture than the canceller's history reaches. Push in step with playback.referenceReanchors is climbing steadily. Capture is being interrupted and the alignment keeps being rebuilt. Look at overruns and at whatever is stalling your capture loop, not at the canceller.erleDb. It rises while echo is being removed, sits near zero before the filter settles, and dips while adaptation is held during double talk. It moves around continuously in normal operation. Use it to see change over time, not as a pass mark; there is no threshold that means "working".
def diagnose(mic: Microphone) -> str:
m = mic.aec_metrics()
if m is None:
return "echo cancellation is off, or capture is not running"
if m.delay_samples is not None:
return f"aligned at {m.delay_samples} samples, double talk {m.double_talk}"
if m.reference_silence >= m.acquisition_parked:
return "no reference is reaching the canceller"
if m.reference_dropped > 0:
return "pushes are being discarded: push smaller blocks, in step with playback"
return "reference is arriving but nothing has aligned: check the rate"
function diagnose(mic) {
const m = mic.aecMetrics();
if (m === null) {
return 'echo cancellation is off, or capture is not running';
}
if (m.delaySamples !== null) {
return `aligned at ${m.delaySamples} samples, double talk ${m.doubleTalk}`;
}
if (m.referenceSilence >= m.acquisitionParked) {
return 'no reference is reaching the canceller';
}
if (m.referenceDropped > 0) {
return 'pushes are being discarded: push smaller blocks, in step with playback';
}
return 'reference is arriving but nothing has aligned: check the rate';
}
Read this before you design around echo cancellation. These are properties of the problem, and knowing them will save you building on an assumption that does not hold.
Echo reduction is governed by the acoustic path between your speaker and your microphone, and that path is out of decibri's hands. A path that is stable, direct, and free of clipping gives good reduction. Ordinary laptop speakers or desktop monitor speakers, a few tens of centimetres from the microphone, in a normal room, give modest reduction. That is the common case, and modest is the expected outcome, not a sign that something is broken.
What helps, in rough order: lower the playback volume, increase the distance between speaker and microphone, avoid driving the speaker into distortion, and keep the physical arrangement fixed while capture runs. Moving the laptop mid-call invalidates the alignment.
The detector reads after cancellation, so it sees less of your playback than it otherwise would. It does not follow that it stops firing on your playback altogether.
Whether it fires depends on whether the residual echo sits below the room's noise, not on how much was removed. Loud playback in a quiet room can leave a residue that is quiet in absolute terms and still well clear of the noise floor, and the detector will call that speech.
If you are enabling echo cancellation specifically to stop your own output triggering speech detection, it will help, and it will not reliably solve it. The robust approach is to also track when you are playing and treat detections during playback accordingly. Do not build a barge-in design that depends on the detector alone staying quiet through your own output.
Double talk is the moment someone cuts in while your own audio is still playing, which is exactly the moment a barge-in design cares about most. With both ends live at once, the canceller holds its adaptation rather than learning from a signal that is no longer only echo. Two consequences: echo reduction during those stretches is lower than when the far end talks alone, and the near-end talker is very slightly affected. doubleTalk in the metrics tells you when this is happening.
Alignment is found by looking for the far-end signal inside the capture. That needs something aperiodic to lock onto. Speech has it. Music with a steady beat, a held tone, a ringtone, a hum, a test sine can all fail to produce a usable alignment, and delaySamples can stay null for the whole of a musical passage.
This is a real use case and worth planning for. If your far end is a media stream, expect echo cancellation to work during speech and to be unreliable during music. An alignment found during speech does carry across into the music that follows, so a stream that opens with a spoken introduction fares better than one that opens on a note.
Symptom: everything runs, no error is raised, and the echo is untouched. delaySamples stays null while acquisitionParked climbs and referenceSilence does not.
Fix: set referenceSampleRate / reference_sample_rate to the rate of the audio you are pushing, and push it unconverted.
Symptom: the echo is untouched, delaySamples stays null, and referenceSilence climbs in step with acquisitionParked.
Fix: push at the point where your code hands audio to the player. If the audio never passes through your code, echo cancellation cannot help.
Symptom: no benefit, and a canceller searching for an echo that does not exist.
Fix: leave the option off. Detect the output route if your application supports both, and only enable it for the loudspeaker case.
Symptom: pushes vanish. referenceDropped stays at zero, because a discarded push is not a dropped sample.
Fix: check that the metrics accessor returns an object before you rely on pushes. In Node, push from inside the playback loop or the data handler.
Symptom: referenceDropped jumps by a large count on a single call, and alignment does not appear.
Fix: the queue spans two seconds at the reference rate. Push in blocks, in step with playback.
Symptom: in Python, a TypeError naming the mismatch. In Node, no error at all: the bytes are taken as they are and reinterpreted in the microphone's dtype, so a Float32Array pushed into an int16 capture is read as noise and nothing aligns.
Fix: push in the same dtype the microphone was constructed with.
stop() to preserve the metricsSymptom: the accessor returns null after capture stops, and a final reading you meant to log is lost.
Fix: read the metrics while capture is still running.
The exhaustive signatures, the config and metrics types, the async surface, and the error tables live on the binding reference pages:
aec option, the Aec and AecMetrics dataclasses, push_aec_reference(), aec_metrics(), the AsyncMicrophone surface, and the exception rows.aec option, the AecOptions and AecMetrics interfaces, pushAecReference(), aecMetrics(), and the error classes with their code values.The same capability is available in the Rust API, where the option and the reference push sit on the capture builder. See the crate documentation for the Rust signatures.