discussion_helper.discussion module

The loop: who holds the floor, and what happens when the user cuts in.

Module summary

Everything else in this package is a part. This is the thing the parts are for: listen, decide the user has finished, answer, speak — and, at any moment while speaking, stop because the user started again.

The hard part is not the sequence. It is the interruption, and three properties of it that a first implementation gets wrong:

It has to be fast. The published bar is under 150 ms from the user’s first syllable to the agent’s silence. Past that the agent is audibly talking over somebody, which is the single rudest thing a voice interface does.

It has to discard, not drain. A reply is synthesised ahead of playback, so when the user interrupts there is audio already made and not yet heard. Playing it out — because it exists, because it was paid for — is what makes an agent feel deaf. Voice.stop() throws it away.

It has to be hard to trigger by accident. The agent’s own voice reaching its own microphone looks exactly like the user speaking. Without acoustic echo cancellation the honest default is to ask more of a barge-in than of ordinary speech, and to say so — see Policy.barge_in_grace_s.

What this module does not do

It does not recognise speech, generate text or synthesise audio. Those live in vocal-helper, a language model, and speaker-helper respectively, and are reached through the protocols in discussion_helper.parts. The loop below is written entirely against those protocols, which is why it can be tested without a microphone.

Author

Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui

class discussion_helper.discussion.Discussion(listener, responder, voice, policy=None)[source]

Bases: object

A spoken conversation between a user and an agent.

Parameters:

Examples

>>> # Every part is injectable, so a whole conversation runs in a test
>>> # with no microphone, no model and no sound card.
>>> from discussion_helper.testing import ScriptedListener, EchoResponder, SilentVoice
>>> import asyncio
>>> talk = Discussion(
...     ScriptedListener(["bonjour"]), EchoResponder(), SilentVoice()
... )
>>> conversation = asyncio.run(talk.run())
>>> [u.text for u in conversation.utterances]
['bonjour', 'bonjour']
async aclose()[source]

Release the microphone, the model and the audio device.

Return type:

None

async run()[source]

Hold the conversation until the listener stops yielding turns.

Returns:

Everything said, with a TurnTiming per exchange.

Return type:

Conversation

async warmup()[source]

Pay every part’s first-call cost before the user says anything.

Concurrently, and never fatally. A part that cannot warm up may still work — and a conversation that refused to start because a pre-load failed would be refusing over something that was optional by construction.

The numbers that make this worth doing are in Warmable: 4.8 seconds on the first model call in a process, 0.25 on the rest. Unwarmed, the whole of that lands on the user’s first question.

Return type:

None

class discussion_helper.discussion.Policy(allow_barge_in=True, barge_in_grace_s=0.35, max_reply_sentences=0, silence_timeout_s=0.0)[source]

Bases: object

The few decisions a conversation needs made for it.

Parameters:
  • allow_barge_in (bool) – Whether the user may interrupt the agent at all. False is strict half-duplex: rude, and the right choice on a speakerphone with no echo cancellation, where every reply would otherwise interrupt itself.

  • barge_in_grace_s (float) – How long after the agent starts speaking before an interruption is believed. This exists because of echo: the agent’s first syllable is the loudest thing the microphone will hear, and without AEC it reads as the user starting to talk. A short grace period costs a listener almost nothing and removes the whole class of self-interruption.

  • max_reply_sentences (int) – A ceiling on how long an answer may run. Not a style preference: an agent that talks for ninety seconds is one the user has to interrupt, and interruption is the expensive path. Zero means no ceiling.

  • silence_timeout_s (float) – How long to wait for the user before giving up on the turn. Zero waits forever, which is correct for a push-to-talk session and wrong for an open microphone in an empty room.

allow_barge_in: bool = True
barge_in_grace_s: float = 0.35
max_reply_sentences: int = 0
silence_timeout_s: float = 0.0