Skip to content

feat(rt+voice): FEOU→EOU latency compensation - #125

Draft
sam-s10s wants to merge 2 commits into
mainfrom
pr/feou-oeu-reduction
Draft

feat(rt+voice): FEOU→EOU latency compensation#125
sam-s10s wants to merge 2 commits into
mainfrom
pr/feou-oeu-reduction

Conversation

@sam-s10s

@sam-s10s sam-s10s commented Jul 26, 2026

Copy link
Copy Markdown
Member

Important

Reference PR — not intended to merge as-is. This branch is based directly off main and isolates only the FEOU→EOU latency compensation so it can be reviewed in isolation. In reality this work was developed on top of several other pr/* branches (op-model, rt-audio-format, voice-forced-eou rework, voice-8khz, ws-headers, …) and should ideally sit on top of those rather than on bare main. It's opened here primarily for reference/review of the compensation itself. Marked as draft.

What this does

When a voice agent decides someone has stopped talking it sends a ForceEndOfUtterance (FEOU) and waits for the engine's EndOfUtterance (EOU). The engine only resolves the FEOU once the decode chunk covering that timestamp is complete, so the client normally waits real-time for the inter-utterance gap to fill it. Instead we inject that silence instantly up to the next chunk boundary (+ margin), so the engine resolves immediately — while leaving the FEOU timestamp at the true speech end.

Measured: ~66% reduction in FEOU→EOU latency, transcript unchanged.

Changes (5 files, FEOU-only)

sdk/rt

  • force_end_of_utterance(compensate_latency=…) injects one block of PCM silence up to the next decode-chunk boundary + margin, then sends the FEOU at the real speech end (never moved). Explicit timestamps are treated as real-audio time and shifted onto the server timeline.
  • Track cumulative injected silence; adjust_timestamp() maps server→real time. A receive-path hook recursively corrects start_time/end_time on every timestamped message, so all listeners see real audio time. No-op until silence is injected.
  • _send_audio_bytes() shared writer so injected silence updates the audio counters without passing through subclass send_audio overrides.

sdk/voice

  • feou_latency_compensation config flag (default True); enables compensation on the forced EOU and matches only the forced EndOfUtterance (forced: true).

Teststests/voice/test_20_feou_latency.py: a live A/B latency comparison plus offline, deterministic unit tests (explicit-timestamp shift + round-trip).

Deliberately excluded (kept "purest")

The speechmatics-rt version pin bump, the Makefile install-dev/install-smart changes, the Smart-Turn audio-buffer fix, and the test_08/test_14 cleanups — all live on the integration branch, not here.

CI caveat

Because the speechmatics-rt pin is not bumped here, CI will resolve speechmatics-rt from PyPI (without these RT methods) unless RT is installed editable. Self-consistent locally; needs the RT side released/pinned (or this rebased onto the RT PR) before CI is green.

Verification

Compiles; ruff + mypy pass; all 3 tests pass on the main base (live −53% run + 2 offline unit tests).

Inject silence ahead of real time to align each forced EOU with the engine's
decode-chunk grid, so the covering chunk completes immediately instead of waiting
real-time for the inter-utterance gap. Measured ~50% reduction in FEOU->EndOfUtterance
latency with the transcript unchanged.

sdk/rt:
- force_end_of_utterance(compensate_latency=...) injects one block of PCM silence up
  to the next decode-chunk boundary + margin, then sends the FEOU at the real speech
  end (unmoved). Explicit timestamps are treated as real-audio time and shifted onto
  the server timeline.
- Track cumulative injected silence; adjust_timestamp() maps server time back to real
  audio time. A receive-path hook recursively corrects incoming start_time/end_time on
  every timestamped message, so all listeners see real audio time. No-op until silence
  is injected.
- Factor the transport write into _send_audio_bytes() so injected silence updates the
  audio counters without passing through subclass send_audio overrides.

sdk/voice:
- Add feou_latency_compensation config flag (default True); enable compensation on the
  forced EOU and match only the forced EndOfUtterance (forced: true).

Add tests/voice/test_20_feou_latency.py: a live A/B latency comparison plus offline,
deterministic unit tests for the explicit-timestamp shift and round-trip.
@sam-s10s sam-s10s changed the title FEOU→EOU latency compensation (reference) feat(rt+voice): FEOU→EOU latency compensation Jul 26, 2026
Gate whether the resolved timestamp is written to the ForceEndOfUtterance message
via include_timestamp (default True). When False, no timestamp is sent and the
engine falls back to its own timing; the marker is still computed so latency
compensation continues to work.

await self._send_audio_bytes(payload)

async def _send_audio_bytes(self, payload: bytes) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious if you haven't seen any issues with this, as this should require a lock, as I assume (haven't seen all the implementation) that this gets called from 2 threads? or does it get serialized by the asyncio loop and its fine?

If its synchronous its should be ok

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants