-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(voice): add user_away_signal for transcript-based away tracking #6880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -235,6 +235,8 @@ class ExpressiveOptions(TypedDict, total=False): | |||||||||||||||||||||||||||||||||
| speech_steering=DEFAULT_SPEECH_STEERING_OPTIONS, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| UserAwaySignal = Literal["audio", "transcript"] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _append_instructions(template: Instructions | str, extra: str) -> Instructions: | ||||||||||||||||||||||||||||||||||
| # concatenate the *raw* template text so any {placeholders} survive until render() | ||||||||||||||||||||||||||||||||||
|
|
@@ -285,6 +287,7 @@ class AgentSessionOptions: | |||||||||||||||||||||||||||||||||
| """sparse endpointing keys the user provided explicitly""" | ||||||||||||||||||||||||||||||||||
| max_tool_steps: int | ||||||||||||||||||||||||||||||||||
| user_away_timeout: float | None | ||||||||||||||||||||||||||||||||||
| user_away_signal: UserAwaySignal | ||||||||||||||||||||||||||||||||||
| transcription_timeout: float | None | ||||||||||||||||||||||||||||||||||
| min_consecutive_speech_delay: float | ||||||||||||||||||||||||||||||||||
| use_tts_aligned_transcript: bool | None | ||||||||||||||||||||||||||||||||||
|
|
@@ -389,6 +392,7 @@ def __init__( | |||||||||||||||||||||||||||||||||
| aec_warmup_duration: NotGivenOr[float | None] = NOT_GIVEN, | ||||||||||||||||||||||||||||||||||
| ivr_detection: bool = False, | ||||||||||||||||||||||||||||||||||
| user_away_timeout: float | None = 15.0, | ||||||||||||||||||||||||||||||||||
| user_away_signal: UserAwaySignal = "audio", | ||||||||||||||||||||||||||||||||||
| transcription_timeout: float | None = None, | ||||||||||||||||||||||||||||||||||
| session_close_transcript_timeout: float = 2.0, | ||||||||||||||||||||||||||||||||||
| # Runtime settings | ||||||||||||||||||||||||||||||||||
|
|
@@ -478,6 +482,10 @@ def __init__( | |||||||||||||||||||||||||||||||||
| user_away_timeout (float, optional): If set, set the user state as | ||||||||||||||||||||||||||||||||||
| "away" after this amount of time after user and agent are silent. | ||||||||||||||||||||||||||||||||||
| Defaults to ``15.0`` s, set to ``None`` to disable. | ||||||||||||||||||||||||||||||||||
| user_away_signal (Literal["audio", "transcript"], optional): Which user | ||||||||||||||||||||||||||||||||||
| activity holds off the "away" state. ``"audio"`` (default) trusts any | ||||||||||||||||||||||||||||||||||
| detected speech. ``"transcript"`` trusts only transcribed speech, so | ||||||||||||||||||||||||||||||||||
| noise that never becomes text is ignored; it needs a streaming STT. | ||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would also suppress the timer for some STTs that send ghost transcripts. Observed with 11labs Scribe v2 from #4043 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same failure on Deepgram SIP, not only Scribe v2. Noise often yields non-empty interims (uh, the, punctuation). If those re-arm the full away window, a drip every few seconds never reaches away. Separate hole on the same path: if away swallows VAD start and end, an interim promotes to speaking and a later final does not demote. The user stays speaking until the next timeout. |
||||||||||||||||||||||||||||||||||
| transcription_timeout (float, optional): If set, emit a | ||||||||||||||||||||||||||||||||||
| ``user_transcription_timeout`` event when VAD detects user speech | ||||||||||||||||||||||||||||||||||
| during the user's turn but no non-empty final transcript arrives | ||||||||||||||||||||||||||||||||||
|
|
@@ -568,6 +576,7 @@ def __init__( | |||||||||||||||||||||||||||||||||
| endpointing_overrides=endpointing_overrides, | ||||||||||||||||||||||||||||||||||
| max_tool_steps=max_tool_steps, | ||||||||||||||||||||||||||||||||||
| user_away_timeout=user_away_timeout, | ||||||||||||||||||||||||||||||||||
| user_away_signal=user_away_signal, | ||||||||||||||||||||||||||||||||||
| transcription_timeout=transcription_timeout, | ||||||||||||||||||||||||||||||||||
| min_consecutive_speech_delay=min_consecutive_speech_delay, | ||||||||||||||||||||||||||||||||||
| tts_text_transforms=( | ||||||||||||||||||||||||||||||||||
|
|
@@ -1949,7 +1958,11 @@ def _update_agent_state( | |||||||||||||||||||||||||||||||||
| self._aec_warmup_remaining, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if state == "listening" and self._user_state == "listening": | ||||||||||||||||||||||||||||||||||
| # the transcript signal ignores speech activity, so noise must not block the re-arm | ||||||||||||||||||||||||||||||||||
| user_idle = self._user_state == "listening" or ( | ||||||||||||||||||||||||||||||||||
| self._opts.user_away_signal == "transcript" and self._user_state == "speaking" | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| if state == "listening" and user_idle: | ||||||||||||||||||||||||||||||||||
| self._set_user_away_timer() | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| self._cancel_user_away_timer() | ||||||||||||||||||||||||||||||||||
|
|
@@ -1962,7 +1975,11 @@ def _update_agent_state( | |||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _update_user_state( | ||||||||||||||||||||||||||||||||||
| self, state: UserState, *, last_speaking_time: float | None = None | ||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||
| state: UserState, | ||||||||||||||||||||||||||||||||||
| *, | ||||||||||||||||||||||||||||||||||
| last_speaking_time: float | None = None, | ||||||||||||||||||||||||||||||||||
| by_transcript: bool = False, | ||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||
| # pinned to "speaking" while a `claim_user_turn` is active; voice | ||||||||||||||||||||||||||||||||||
| # transitions are recoverable from `_user_silence_event` on release | ||||||||||||||||||||||||||||||||||
|
|
@@ -1972,6 +1989,14 @@ def _update_user_state( | |||||||||||||||||||||||||||||||||
| if self._user_state == state: | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||
| self._opts.user_away_signal == "transcript" | ||||||||||||||||||||||||||||||||||
| and self._user_state == "away" | ||||||||||||||||||||||||||||||||||
| and not by_transcript | ||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||
| # only a transcript ends "away"; noise would otherwise clear it at once | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+1992
to
+1998
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Typing a message while marked away leaves the user stuck as away for the rest of the call Speech-independent user turns such as a typed chat message are refused ( Impact: With the transcript away signal enabled, a user who returns via text (the default text-input path) is still reported as away for the remainder of the session, so "are you still there?" style logic and any app code keyed on the user state behave as if nobody is present. Why the away state can never be cleared by a text turn
Both calls it makes — Nothing later restores it: under the transcript signal
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| last_speaking_time_ns = ( | ||||||||||||||||||||||||||||||||||
| int(last_speaking_time * 1_000_000_000) if last_speaking_time else None | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
@@ -1993,10 +2018,11 @@ def _update_user_state( | |||||||||||||||||||||||||||||||||
| self._user_speaking_span.end(end_time=last_speaking_time_ns) | ||||||||||||||||||||||||||||||||||
| self._user_speaking_span = None | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if state == "listening" and self._agent_state == "listening": | ||||||||||||||||||||||||||||||||||
| self._set_user_away_timer() | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| self._cancel_user_away_timer() | ||||||||||||||||||||||||||||||||||
| if self._opts.user_away_signal == "audio": | ||||||||||||||||||||||||||||||||||
| if state == "listening" and self._agent_state == "listening": | ||||||||||||||||||||||||||||||||||
| self._set_user_away_timer() | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| self._cancel_user_away_timer() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| old_state = self._user_state | ||||||||||||||||||||||||||||||||||
| self._user_state = state | ||||||||||||||||||||||||||||||||||
|
|
@@ -2022,7 +2048,18 @@ def _user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None: | |||||||||||||||||||||||||||||||||
| # a transcript means stt recovered; reset its error tolerance | ||||||||||||||||||||||||||||||||||
| self._stt_error_counts = 0 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if ev.is_final and self.user_state != "speaking": | ||||||||||||||||||||||||||||||||||
| if self._opts.user_away_signal == "transcript": | ||||||||||||||||||||||||||||||||||
| if ev.transcript: | ||||||||||||||||||||||||||||||||||
| # interims count: a long answer must not trip "away" before its final | ||||||||||||||||||||||||||||||||||
| if self.user_state == "away": | ||||||||||||||||||||||||||||||||||
| # "away" swallowed this turn's speech start, so restore what the | ||||||||||||||||||||||||||||||||||
| # detector sees: an interim still has a speech end coming, a final may not | ||||||||||||||||||||||||||||||||||
| self._update_user_state( | ||||||||||||||||||||||||||||||||||
| "listening" if ev.is_final else "speaking", by_transcript=True | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| if self._agent_state == "listening": | ||||||||||||||||||||||||||||||||||
| self._set_user_away_timer() | ||||||||||||||||||||||||||||||||||
| elif ev.is_final and self.user_state != "speaking": | ||||||||||||||||||||||||||||||||||
| if self.user_state == "away": | ||||||||||||||||||||||||||||||||||
| # reset user state from away to listening in case VAD has a miss detection | ||||||||||||||||||||||||||||||||||
| self._update_user_state("listening") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should mention the audio signal can come from either vad or STT.