Skip to content

Commit b861eb7

Browse files
adamweeksAdam Weeks
andauthored
feat(gecx): stream caller audio in real time (#43)
Co-authored-by: Adam Weeks <adweeks+cisco@cisco.com>
1 parent e1f9f52 commit b861eb7

8 files changed

Lines changed: 744 additions & 124 deletions

File tree

config/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,11 @@ connectors:
138138
# Trailing codec silence for reliable CES audio endpoint detection.
139139
endpointing_silence_ms: 2000
140140
input_preroll_ms: 500
141+
input_holdback_ms: 250
141142
input_pause_preroll_ms: 250
143+
input_stream_chunk_ms: 100
144+
input_queue_max_chunks: 20
145+
input_queue_put_timeout_ms: 50
142146
terminal_response_grace_seconds: 3
143147
# Omit auth settings to use Application Default Credentials.
144148
# service_account_key: "/path/to/service-account.json"
@@ -155,6 +159,11 @@ connector initialization; broader output formats are not silently mislabeled.
155159
The leading-audio guard activates only when the first CES frame is at least
156160
`output_leading_audio_min_ms` and contains no sustained speech. It then retains
157161
`output_speech_preroll_ms` before the first detected speech frames.
162+
Caller input follows a separate progressive path: bounded pre-roll is flushed
163+
at speech start, active normalized audio is queued as it arrives, and only the
164+
configured `input_holdback_ms` tail waits for a committed speech end. The CES
165+
input queue is bounded; sustained backpressure ends the session explicitly
166+
instead of accumulating latency or silently dropping caller audio.
158167
When the gateway detects caller speech, it also isolates the next response turn
159168
until CES sends a recognition result. An interruption signal alone does not
160169
open the gate because CES can send the stale turn completion immediately after

config/config.cloudrun.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ gateway:
2020
response_queue_maxsize: 100
2121

2222
# Gateway-owned speech boundaries sent to Webex Contact Center. GECX still
23-
# receives each caller-audio frame immediately through BidiRunSession.
23+
# receives caller audio progressively through BidiRunSession with a bounded
24+
# tail holdback for natural-pause handling.
2425
voice_activity_detection:
2526
threshold: 0.5
2627
start_debounce_ms: 96
@@ -59,7 +60,11 @@ connectors:
5960
turn_response_timeout_seconds: 30
6061
endpointing_silence_ms: 2000
6162
input_preroll_ms: 500
63+
input_holdback_ms: 250
6264
input_pause_preroll_ms: 250
65+
input_stream_chunk_ms: 100
66+
input_queue_max_chunks: 20
67+
input_queue_put_timeout_ms: 50
6368
terminal_response_grace_seconds: 3
6469
agents:
6570
- "My GECX Agent"

config/config.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,14 @@ connectors:
108108
# turn_response_timeout_seconds: 30
109109
# # Trailing codec silence lets CES reliably endpoint short voice turns.
110110
# endpointing_silence_ms: 2000
111-
# # Preserve speech before VAD start, merge bounded resumed-speech onset,
112-
# # and catch delayed EndSession after TTS.
111+
# # Preserve speech before VAD start, stream with a bounded 250ms tail,
112+
# # merge bounded resumed-speech onset, and cap CES input backpressure.
113113
# input_preroll_ms: 500
114+
# input_holdback_ms: 250
114115
# input_pause_preroll_ms: 250
116+
# input_stream_chunk_ms: 100
117+
# input_queue_max_chunks: 20
118+
# input_queue_put_timeout_ms: 50
115119
# terminal_response_grace_seconds: 3
116120
# # Omit auth settings to use Application Default Credentials.
117121
# # service_account_key: "/path/to/service-account.json"

config/gecx_example.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,26 @@ gecx_connector:
5252
barge_in_enabled: false
5353
force_input_format: "wxcc"
5454
turn_response_timeout_seconds: 30
55-
# Queued after gateway VAD speech end so CES can endpoint short utterances.
55+
# Queued after the progressively streamed caller tail so CES can reliably
56+
# endpoint short utterances. This is codec-correct synthetic silence, not a
57+
# separate CES end-of-audio control.
5658
# Keep margin above one second; an exact one-second tail can leave a CES
5759
# audio turn open until the caller speaks again.
5860
endpointing_silence_ms: 2000
59-
# Buffer through gateway VAD end so CES receives one intact caller turn.
61+
# Retain only bounded audio before gateway VAD detects speech so the first
62+
# word is not clipped, then stream active audio progressively.
6063
input_preroll_ms: 500
64+
# Keep only a small unsent tail. This lets a natural pause resume without
65+
# trying to retract audio already delivered to CES.
66+
input_holdback_ms: 250
6167
# Retain only bounded onset audio when speech resumes during end grace.
6268
input_pause_preroll_ms: 250
69+
# Cap individual CES caller-audio messages and the total pending queue.
70+
# A full queue waits briefly, then terminates explicitly instead of growing
71+
# latency or silently dropping caller audio.
72+
input_stream_chunk_ms: 100
73+
input_queue_max_chunks: 20
74+
input_queue_put_timeout_ms: 50
6375
# Some EndSession signals follow the final TTS frames.
6476
terminal_response_grace_seconds: 3
6577

docs/guides/byova-gecx-setup.md

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ gecx_connector:
101101
barge_in_enabled: false
102102
force_input_format: "wxcc"
103103
turn_response_timeout_seconds: 30
104+
endpointing_silence_ms: 2000
105+
input_preroll_ms: 500
106+
input_holdback_ms: 250
107+
input_pause_preroll_ms: 250
108+
input_stream_chunk_ms: 100
109+
input_queue_max_chunks: 20
110+
input_queue_put_timeout_ms: 50
104111
# Omit auth entirely to use Application Default Credentials (recommended on
105112
# Google Cloud; the runtime service account needs roles/ces.client).
106113
# service_account_key: "C:/path/to/ces-service-account.json"
@@ -181,12 +188,21 @@ are worth understanding if you fork this connector.
181188
### Real-time streaming bridge
182189

183190
`GECXStreamingSession` runs a background thread per conversation that holds one
184-
CES `BidiRunSession` open. WxCC caller audio is pushed onto an inbound queue and
185-
forwarded to CES; CES server messages (STT, agent text, TTS audio,
186-
interruption, and end-of-session) are mapped to BYOVA responses on an outbound
187-
queue. After the gateway emits `END_OF_INPUT`, it consumes that queue
188-
incrementally instead of materializing the complete turn. The first CES audio
189-
frame can therefore reach WxCC before CES emits `turn_completed`.
191+
CES `BidiRunSession` open. Before speech, it retains only bounded pre-roll. At
192+
`START_OF_INPUT`, it queues that pre-roll immediately; while speech is active,
193+
it forwards normalized caller audio as frames arrive while retaining only a
194+
small `input_holdback_ms` tail. `END_OF_INPUT` never requeues the complete
195+
utterance: it adds the unsent tail and codec-correct endpointing silence. CES
196+
server messages (recognition, agent text, TTS audio, interruption, and
197+
end-of-session) are mapped to BYOVA responses on an outbound queue.
198+
199+
The CES input queue is bounded by `input_queue_max_chunks`, and each audio item
200+
is at most `input_stream_chunk_ms`. A full queue applies the configured short
201+
put timeout and then terminates explicitly; it does not grow memory, silently
202+
drop audio, or accumulate unbounded latency. CES may recognize caller audio
203+
before gateway VAD commits `END_OF_INPUT`, but caller-owned output remains
204+
queued until that boundary so WxCC response ordering is preserved. The first
205+
CES response-audio frame can then reach WxCC before CES emits `turn_completed`.
190206

191207
CES can also produce an autonomous no-input prompt after the preceding
192208
caller-owned response has already reached `FINAL`. The connector publishes
@@ -198,7 +214,7 @@ validated end to end. When enabled, WxCC continues forwarding caller audio
198214
while CES waits for an interruption or a reply.
199215

200216
When gateway VAD emits `START_OF_INPUT`, the connector opens an isolated caller
201-
turn and waits for CES to acknowledge the committed audio with a recognition
217+
turn and waits for CES to acknowledge the streaming audio with a recognition
202218
result. An interruption signal does not open the gate because CES can complete
203219
the interrupted no-input turn immediately afterward. CES output produced before
204220
recognition belongs to that overlapping autonomous turn and is suppressed. The
@@ -235,10 +251,12 @@ caller-audio frame is ingested while CES responses are being produced.
235251
At an apparent speech end, the gateway holds `END_OF_INPUT` for
236252
`speech_end_grace_ms` (default: `1000`, maximum: `2000`). With the default
237253
`end_silence_ms` value, this creates a bounded two-second natural-pause window.
238-
If speech resumes in that window, GECX removes the endpoint-triggering pause,
239-
merges up to `input_pause_preroll_ms` of the resumed onset, and keeps one CES
240-
input turn. Otherwise it commits the boundary normally. Configure the observer
241-
under the top-level `voice_activity_detection` block in `config/config.yaml`.
254+
If speech resumes in that window, GECX discards only its unsent tail, forwards
255+
up to `input_pause_preroll_ms` of resumed onset, and keeps one CES input turn
256+
without emitting a duplicate endpoint. Audio already streamed to CES is never
257+
retracted. Otherwise it commits the boundary by forwarding the held tail and
258+
configured endpointing silence. Configure the observer under the top-level
259+
`voice_activity_detection` block in `config/config.yaml`.
242260

243261
### Output audio: raw 8 kHz mu-law BYOVA chunks
244262

@@ -400,9 +418,13 @@ window for an `EndSession` that follows the final TTS frames.
400418
| `output_speech_preroll_ms` | No | Audio retained immediately before detected speech (default: `100`) |
401419
| `force_input_format` | No | `wxcc` forces 8 kHz MULAW when input metadata is unavailable |
402420
| `turn_response_timeout_seconds` | No | Maximum wait after gateway speech end for CES to complete the agent turn (default: `30`) |
403-
| `endpointing_silence_ms` | No | Codec-correct silence appended to each buffered caller turn for CES endpoint detection (default: `2000`; one second may leave a turn open until more audio arrives) |
421+
| `endpointing_silence_ms` | No | Codec-correct silence appended after the progressively streamed caller tail for CES endpoint detection (default: `2000`; one second may leave a turn open until more audio arrives) |
404422
| `input_preroll_ms` | No | Bounded caller audio retained before gateway speech start to avoid clipping (default: `500`) |
423+
| `input_holdback_ms` | No | Small unsent active-audio tail used for pause/resume and committed endpointing (default: `250`, maximum: `500`) |
405424
| `input_pause_preroll_ms` | No | Maximum onset audio retained while a possible speech end is held, then merged if the caller resumes (default: `250`) |
425+
| `input_stream_chunk_ms` | No | Maximum duration of each queued CES caller-audio item (default: `100`, range: `20`-`100`) |
426+
| `input_queue_max_chunks` | No | Maximum pending CES input items before explicit backpressure failure (default: `20`, range: `1`-`200`) |
427+
| `input_queue_put_timeout_ms` | No | Maximum wait for CES input queue capacity before terminating the session (default: `50`, maximum: `1000`) |
406428
| `terminal_response_grace_seconds` | No | Wait for delayed `EndSession` after a terminal-sounding TTS turn (default: `3`) |
407429
| `transfer_metadata_keys` | No | EndSession metadata keys that, when truthy, trigger a human transfer (see [Escalation](#escalation-to-a-human-agent)) |
408430
| `transfer_reason_keywords` | No | Substrings that, if found in a reason/type metadata value, trigger a transfer |
@@ -433,11 +455,12 @@ create the JSON cache.
433455
| Stream fails on start | `roles/ces.client`, API enabled, correct `location` |
434456
| `404` / `UNIMPLEMENTED` on BidiRunSession | Wrong endpoint — must be regional `ces.<location>.rep.googleapis.com` (auto-derived from `location`) |
435457
| `429 Resource exhausted` | CES per-app session quota; retry/backoff or request more quota |
436-
| No audio to caller (silence) | Confirm `gecx_first_audio_chunk` appears, the next response is a BYOVA `CHUNK`, and output remains `MULAW` / `8000`. See [Output audio](#output-audio-raw-8-khz-mu-law-byova-chunks). |
458+
| No audio to caller (silence) | Confirm `gecx_first_response_audio` appears, the next response is a BYOVA `CHUNK`, and output remains `MULAW` / `8000`. See [Output audio](#output-audio-raw-8-khz-mu-law-byova-chunks). |
437459
| Long static/noise before a prompt | Look for `gecx_long_leading_audio_detected` followed by `gecx_leading_audio_suppressed`; tune the guarded output settings only with captured CES evidence. |
438460
| Garbled speech | Confirm the gateway logs the declared WxCC encoding/sample rate; use `force_input_format: "wxcc"` only when the client omits metadata |
439-
| Agent recognizes the caller but its reply is not audible | Confirm `gecx_pre_input_output_suppressed` is followed by `gecx_caller_input_acknowledged`, `gecx_first_audio_chunk`, and `gecx_streamed_turn_complete` for the same conversation. |
440-
| CES logs a no-input prompt but the caller does not hear it | Confirm `gecx_first_audio_chunk` reports `delivery_mode=async`; its `barge_in_enabled` value should match connector configuration. Verify the active WxCC stream did not cancel before that timestamp. |
461+
| Agent recognizes the caller but its reply is not audible | Confirm `gecx_pre_input_output_suppressed` is followed by `gecx_caller_input_acknowledged`, `gecx_first_response_audio`, and `gecx_streamed_turn_complete` for the same conversation. |
462+
| CES logs a no-input prompt but the caller does not hear it | Confirm `gecx_first_response_audio` reports `delivery_mode=async`; its `barge_in_enabled` value should match connector configuration. Verify the active WxCC stream did not cancel before that timestamp. |
463+
| Session ends with input backpressure | Correlate `gecx_input_queue_backpressure` with CES/network health. Increase `input_queue_max_chunks` only after measuring frame cadence and acceptable latency; do not mask a stalled CES stream with an unbounded queue. |
441464
| No response after `END_OF_INPUT` | Check for `turn_completed` or a turn-completion timeout in `[GECX]` logs; increase `turn_response_timeout_seconds` if the agent regularly needs more than 30 seconds |
442465
| `GoAway` from CES | The connector intentionally emits one `SESSION_END` and half-closes CES; it does not reconnect in the current implementation |
443466
| Import error | `pip install google-cloud-ces` |
@@ -447,10 +470,22 @@ create the JSON cache.
447470
Search gateway logs for `[GECX]`:
448471

449472
- `Starting conversation` — session created
450-
- `STT` — recognition results from CES
473+
- `gateway_caller_speech_start_detected` / `gecx_caller_speech_start` — gateway
474+
VAD speech start and the pre-roll queued for the CES input turn
475+
- `gecx_first_caller_audio_sent` — first caller-audio frame yielded to CES,
476+
including source, frame size, queue depth, and latency from speech start
477+
- `gecx_caller_audio_streamed` — DEBUG cumulative caller-audio frame/byte counts
478+
- `gateway_caller_speech_end_detected` / `gecx_caller_speech_end_detected` —
479+
gateway VAD speech end, grace configuration, and the bounded unsent tail
480+
- `gecx_caller_endpoint_queued` / `gecx_caller_endpoint_committed` — only the
481+
held tail and configured codec silence were ordered after streamed audio
482+
- `gecx_recognition_received` — recognition timing and transcript length only;
483+
transcript content is never logged
451484
- `Agent` — text responses
452-
- `gecx_first_audio_chunk` — first raw CES frame published for WxCC, including
485+
- `gecx_first_response_audio` — first raw CES frame published for WxCC, including
453486
first-frame latency, `async`/`turn` delivery mode, and barge-in state
487+
- `gecx_input_queue_backpressure` — the bounded CES input queue remained full
488+
past its put timeout and the session was terminated explicitly
454489
- `gecx_long_leading_audio_detected` — an anomalously long low-energy CES
455490
prefix activated the guarded speech gate
456491
- `gecx_leading_audio_suppressed` — the gate opened on sustained speech and

0 commit comments

Comments
 (0)