discussion_helper package

Submodules

Module contents

discussion-helper — a spoken conversation, with the floor handled properly.

Module summary

vocal-helper hears. speaker-helper speaks. A language model answers. None of them knows whose turn it is, and that is what this package is: the loop between the three, and the three questions it has to get right.

Who holds the floor. Exactly one party at a time. Most voice-agent failures are two parties believing they hold it — the agent talking over the user is the obvious one, both waiting in silence is the one nobody notices.

What happens when the user cuts in. Under 150 ms from their first syllable to the agent’s silence, and the audio already synthesised and not yet heard is discarded, not drained. Playing it out because it exists is what makes an agent feel deaf.

Where the latency went. A single total says a conversation feels slow and nothing about why. Every exchange reports its breakdown — endpointing, recognition, first token, first audio — and names the stage to fix.

The three parts are protocols, so the whole loop runs in a test with no microphone, no model and no sound card. That is not tidiness: interruption handling is exactly the behaviour a real room makes impossible to assert.

Usage example

>>> import asyncio
>>> from discussion_helper import Discussion
>>> from discussion_helper.testing import ScriptedListener, EchoResponder, SilentVoice
>>> talk = Discussion(ScriptedListener(["bonjour"]), EchoResponder(), SilentVoice())
>>> [u.text for u in asyncio.run(talk.run()).utterances]
['bonjour', 'bonjour']

See KNOWLEDGE.md for the design notes: the latency budget, turn detection, barge-in, and why acoustic echo is the problem that decides your defaults.

Author

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

class discussion_helper.Conversation(utterances=<factory>, floor=Floor.IDLE, timings=<factory>)[source]

Bases: object

Everything said so far, and whose turn it is.

The history is what the language model is shown, so it is kept as utterances rather than as a rendered prompt: a prompt is one consumer’s view of this, and building it here would make every other consumer read somebody else’s formatting.

Parameters:
add(utterance)[source]

Append a turn.

Parameters:

utterance (Utterance)

Return type:

None

as_messages()[source]

The history in the shape chat models expect.

Return type:

list[dict[str, str]]

floor: Floor = 'idle'
property interruption_rate: float

Share of agent turns the user cut into.

A diagnostic, not a score. A high rate usually means the agent answers at too great a length — which a transcript will not show, because the text of an interrupted answer looks complete.

property last_user_text: str

What the user most recently said, or "".

timings: list[TurnTiming]
utterances: list[Utterance]
class discussion_helper.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.Floor(*values)[source]

Bases: Enum

Who may speak, right now.

A spoken conversation has exactly one floor and exactly one holder. Most of the failures in a voice agent are two parties believing they hold it: the agent talking over the user is the obvious one, and both waiting in silence is the one nobody notices until a recording is played back.

The states are deliberately few. THINKING is separate from SPEAKING because they are interrupted differently — thinking can be abandoned silently, speaking has to be stopped, and the audio already queued has to be thrown away rather than drained.

IDLE = 'idle'

Nothing is happening. The loop has not started or has been stopped.

LISTENING = 'listening'

The user has the floor. The microphone is open and nothing is playing.

SPEAKING = 'speaking'

The agent has the floor. Audio is playing and a barge-in may arrive.

THINKING = 'thinking'

The user has finished and the answer is being produced. Silence, and the part of the budget the listener cannot shorten.

class discussion_helper.Listener(*args, **kwargs)[source]

Bases: Protocol

Turns an open microphone into finished user turns.

One method and one event, because those are two different questions and conflating them is how barge-in gets built wrong.

utterances() answers “what did they say, and when did they stop” — it yields only completed turns, after whatever endpointing the implementation does.

speech_started() answers “is somebody talking right now”, which has to be available while the agent is speaking and long before any transcript exists. A barge-in cannot wait for a transcript: by the time one exists the agent has been talking over the user for a second.

async aclose()[source]

Release the microphone.

Return type:

None

async speech_started()[source]

Return as soon as the user starts speaking.

Awaited only while the agent holds the floor. An implementation that cannot detect this should never return, which degrades the conversation to strict half-duplex rather than breaking it.

Return type:

None

utterances()[source]

Yield completed user turns, in order.

Return type:

AsyncIterator[Utterance]

class discussion_helper.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
class discussion_helper.Responder(*args, **kwargs)[source]

Bases: Protocol

Turns the conversation so far into an answer, a sentence at a time.

Yielding sentences rather than returning an answer is the whole design. Waiting for a complete reply before speaking adds its entire generation time to the silence the user sits through; yielding the first sentence as soon as it is complete removes all but its own share.

Sentence-sized, specifically — not token-sized. Synthesis models use sentence context to shape intonation, so a chunk cut at a comma comes out flat or oddly stressed. The unit that is both fast enough and prosodically whole is one sentence.

async aclose()[source]

Release whatever the model holds.

Return type:

None

reply(messages)[source]

Yield the answer, one speakable sentence at a time.

Parameters:

messages (list[dict[str, str]])

Return type:

AsyncIterator[str]

class discussion_helper.TurnTiming(endpoint_s=0.0, recognise_s=0.0, think_s=0.0, speak_s=0.0)[source]

Bases: object

Where one turn’s latency went.

The number a listener feels is to_first_audio_s: the silence between their last word and the agent’s first sound. Everything else here is the breakdown that says which stage to fix, and the breakdown is the point — a single total tells you a conversation feels slow and nothing about why.

Typical shares, from the published budgets this package was built against: endpointing 150–350 ms, recognition 100–150 ms, the language model’s first token 150–400 ms, synthesis to first audio 100–200 ms. Under 800 ms end to end is the usual target; a well-tuned local pipeline lands near 600.

Parameters:
  • endpoint_s (float) – From the user’s last word to the decision that they had finished. The largest line, and the one a semantic turn detector shortens.

  • recognise_s (float) – Finishing the transcript once the turn is closed.

  • think_s (float) – To the first token of the answer. Not the whole answer: a reply is spoken sentence by sentence, so only the first one is on this path.

  • speak_s (float) – From that first sentence to the first sample leaving the speaker.

as_dict()[source]

The breakdown plus its total, for a log line or a report.

slowest_stage belongs in here rather than being bolted on by each caller. It was not, and the three doors disagreed: the text CLI named the stage to fix, the HTTP event stream merged the key in by hand, and the CLI’s own --json — the one a script reads — omitted it. One serialisation, so a reader gets the same breakdown whichever door they came through.

Return type:

dict[str, float | str]

endpoint_s: float = 0.0
recognise_s: float = 0.0
property slowest_stage: str

Which stage to fix first. Naming it is most of fixing it.

speak_s: float = 0.0
think_s: float = 0.0
property to_first_audio_s: float

The silence the user actually sits through.

class discussion_helper.Utterance(speaker, text, started_s=0.0, ended_s=0.0, interrupted=False)[source]

Bases: object

One thing somebody said, with when it was said.

Parameters:
  • speaker (str) – "user" or "agent".

  • text (str) – What was said. For the user this is the transcript and may be wrong; for the agent it is exactly what was synthesised.

  • started_s (float) – Seconds since the conversation began. For the user these bound the speech itself, not the recognition — the gap between the two is the single largest line in the latency budget, and conflating them would hide it.

  • ended_s (float) – Seconds since the conversation began. For the user these bound the speech itself, not the recognition — the gap between the two is the single largest line in the latency budget, and conflating them would hide it.

  • interrupted (bool) – Set on an agent turn the user cut into. Worth keeping: an agent that is interrupted often is usually one that answers at too great a length, and that is invisible in a transcript.

property duration_s: float

How long the speaking took.

ended_s: float = 0.0
interrupted: bool = False
speaker: Literal['user', 'agent']
started_s: float = 0.0
text: str
class discussion_helper.Voice(*args, **kwargs)[source]

Bases: Protocol

Speaks a sentence, and can be stopped in the middle of it.

stop() is the method that matters, and the one a naive implementation gets wrong by draining the queue instead of discarding it. When the user interrupts, every sentence already synthesised and not yet heard must be thrown away. Playing it out “because it was already generated” is exactly the behaviour that makes an agent feel deaf.

async aclose()[source]

Release the audio device and the engine.

Return type:

None

async speak(sentence)[source]

Speak one sentence and report what it cost.

The return type carries two durations on purpose. The loop needs the time to the first sample — that is the silence the listener sits through — and reporting only “how long speaking took” would make a fast engine playing a long sentence look slower than a slow engine playing a short one.

Parameters:

sentence (str)

Return type:

SpokenSentence

async stop()[source]

Stop immediately and discard anything queued.

Return type:

None