standard_asr.runtime.streaming
Streaming session management: the event protocol, segment lifecycle, stability guarantees, the sync bridge, and the stream reducer.
Full-duplex streaming transcription protocol (spec, section "Streaming").
This module defines the streaming event model and session machinery:
TranscriptionEvent-- the 6-type event (partial/final/supersede/progress/done/error) carrying a stablesegment_id, cumulativetext, a conservativestable_untilcodepoint frontier, and anaudio_processed_untilcursor.validate_stable_until-- enforces the combining-character invariant (stable_untilMUST NOT split a combining sequence) using stdlibunicodedataonly.reduce_event/StreamReducer-- the canonical application-side reduce (including the coresupersedehandling) and reduction of a session to aTranscriptionResult.TranscriptionSession-- an async-first, full-duplex session base. Authors implement the async_open/_produce/_closehooks; the base providesfeedvs manualsend_audio/end_audiosingle ownership, bounded backpressure-aware iteration, lifecycle enforcement, a bounded rolling audio buffer + reconnect scaffolding, three termination deadlines (a pipeline-inactivity backstop plus opt-in idle and wall-clock caps), and result reduction.SyncSession-- the standard sync bridge (one background event loop in a thread, owned by the session), so authors only ever write async. Lifecycle submits carry a timeout so a hanging engine can never deadlock the caller.
DEFAULT_AUDIO_HISTORY_MAXLEN#attributeSource
DEFAULT_AUDIO_HISTORY_MAXLEN = 256DEFAULT_AUDIO_QUEUE_MAXSIZE#attributeSource
DEFAULT_AUDIO_QUEUE_MAXSIZE = 256DEFAULT_DONE_TIMEOUT#attributeSource
DEFAULT_DONE_TIMEOUT: float | None = 300.0DEFAULT_EVENT_BUFFER_CAPACITY#attributeSource
DEFAULT_EVENT_BUFFER_CAPACITY = 1024DEFAULT_MAX_GUARD_DIAGNOSTICS#attributeSource
DEFAULT_MAX_GUARD_DIAGNOSTICS = 1000DEFAULT_MAX_IDLE#attributeSource
DEFAULT_MAX_IDLE: float | None = NoneDEFAULT_MAX_SESSION_SECONDS#attributeSource
DEFAULT_MAX_SESSION_SECONDS: float | None = NoneDIAG_AUDIO_CURSOR_DECREASED#attributeSource
DIAG_AUDIO_CURSOR_DECREASED = 'audio_cursor_decreased'DIAG_FROZEN_PREFIX_REWRITTEN#attributeSource
DIAG_FROZEN_PREFIX_REWRITTEN = 'frozen_prefix_rewritten'DIAG_FROZEN_PREFIX_REWRITTEN_SUPERSEDE#attributeSource
DIAG_FROZEN_PREFIX_REWRITTEN_SUPERSEDE = 'frozen_prefix_rewritten_supersede'DIAG_FROZEN_SPEAKER_REWRITTEN#attributeSource
DIAG_FROZEN_SPEAKER_REWRITTEN = 'frozen_speaker_rewritten'DIAG_LIFECYCLE_AFTER_TERMINAL#attributeSource
DIAG_LIFECYCLE_AFTER_TERMINAL = 'lifecycle_after_terminal'DIAG_LIFECYCLE_CLOSED_SUPERSEDED#attributeSource
DIAG_LIFECYCLE_CLOSED_SUPERSEDED = 'lifecycle_closed_superseded'DIAG_LIFECYCLE_FINAL_AFTER_FINAL#attributeSource
DIAG_LIFECYCLE_FINAL_AFTER_FINAL = 'lifecycle_final_after_final'DIAG_LIFECYCLE_PARTIAL_AFTER_FINAL#attributeSource
DIAG_LIFECYCLE_PARTIAL_AFTER_FINAL = 'lifecycle_partial_after_final'DIAG_LIFECYCLE_RETIRED_RESUPERSEDED#attributeSource
DIAG_LIFECYCLE_RETIRED_RESUPERSEDED = 'lifecycle_retired_resuperseded'DIAG_SEGMENT_TIMESTAMPS_UNAVAILABLE#attributeSource
DIAG_SEGMENT_TIMESTAMPS_UNAVAILABLE = 'segment_timestamps_unavailable'DIAG_STABLE_UNTIL_CLAMPED#attributeSource
DIAG_STABLE_UNTIL_CLAMPED = 'stable_until_clamped'DIAG_SUPERSEDE_CROSS_SPEAKER_MERGE#attributeSource
DIAG_SUPERSEDE_CROSS_SPEAKER_MERGE = 'supersede_cross_speaker_merge'DIAG_SUPERSEDE_DELETES_FROZEN_TEXT#attributeSource
DIAG_SUPERSEDE_DELETES_FROZEN_TEXT = 'supersede_deletes_frozen_text'DIAG_SUPERSEDE_OBLIGATION_UNFULFILLED#attributeSource
DIAG_SUPERSEDE_OBLIGATION_UNFULFILLED = 'supersede_obligation_unfulfilled'DIAG_SUPERSEDE_REINTRODUCES_SEGMENT#attributeSource
DIAG_SUPERSEDE_REINTRODUCES_SEGMENT = 'supersede_reintroduces_segment'DIAGNOSTICS_TRUNCATED_CODE#attributeSource
DIAGNOSTICS_TRUNCATED_CODE = 'diagnostics_truncated'DIAG_SUPERSEDE_UNKNOWN_OLD_ID#attributeSource
DIAG_SUPERSEDE_UNKNOWN_OLD_ID = 'supersede_unknown_old_id'EventType#attributeSource
EventType = Literal['partial', 'final', 'supersede', 'progress', 'done', 'error']StreamDeadlines#classSource
class StreamDeadlines(BaseModel)Application-side overrides for a session's termination deadlines.
Pass to StandardASR.start_transcription(deadlines=...). Only fields you
explicitly set are applied; unset fields keep whatever the engine (or the
standard default) chose. Precedence: application explicit > engine
construction choice > standard default.
The fields mirror the TranscriptionSession deadline parameters --
see there for full semantics. Each accepts None to explicitly disable
that deadline.
| Name | Type | Description |
|---|---|---|
done_timeout= DEFAULT_DONE_TIMEOUT | float | Nonegt=0.0 | The pipeline-inactivity hang backstop, reset by events AND by audio consumption. |
max_idle= DEFAULT_MAX_IDLE | float | Nonegt=0.0 | The opt-in content-stall detector. |
max_session_seconds= DEFAULT_MAX_SESSION_SECONDS | float | Nonegt=0.0 | The opt-in absolute wall-clock cap. |
StreamReducer#classSource
class StreamReducerReduces a stream of events into a TranscriptionResult.
Tracks committed (finalized) segments on a reading-order ledger
(_ReadingOrderLedger): every id claims its position at FIRST
mention -- a partial claims a position even before any final (the
spec's declaration rule) -- and a supersede splices its
replacements into the retired block's position IN PLACE, so a
mid-stream re-segmentation cannot reorder an untimestamped transcript.
The reducer enforces exactly the invariants its own order-soundness
requires -- a supersede whose old_ids are unknown, retired, or not
a contiguous in-order block, a supersede reintroducing a known id, and
a partial or final for a retired id are each SUPPRESSED with a
warning diagnostic (surfaced via result), mirroring the session
guard's semantics so a standalone reducer degrades identically. A
suppressed event commits NOTHING -- not even the sticky
detected_language (the admitted-only commit discipline of
add). Full
lifecycle enforcement (frozen prefixes, speakers, stable_until)
remains the session guard's job: events reaching this reducer through
TranscriptionSession are already guard-filtered, so these
checks fire only for raw non-compliant streams.
Timestamp handling: many engines (for example, Qwen3 streaming) emit no timestamps.
The engine's measurement is stored VERBATIM -- a missing start/end
stays None on the reduced Segment
(timestamp_status derives from the values; nothing is fabricated and
there is no side-channel marker) -- and the reducer preserves reading
order, sorting by start only when every retained segment
carries one.
Initialize an empty reducer.
| Name | Type | Description |
|---|---|---|
detected_language | str | None | The sticky session-level detected language reduced so far. The last non- |
add#methodSource
def StreamReducer.add(event: TranscriptionEvent) -> NoneIncorporate one event into the running result.
State commits only on the ADMITTED path: every suppression below
returns before the method's tail, and the sticky
detected_language is committed at that tail -- so a suppressed
event changes nothing, not even session-level metadata (the same
admitted-only commit discipline the session guard applies to its
audio_processed_until cursor). Committing the language on entry
let a suppressed supersede's detected_language rewrite
result() while the event itself was refused -- an event stream
and its reduction silently disagreeing about what happened.
| Name | Type | Description |
|---|---|---|
event | TranscriptionEvent | The event to incorporate. |
result#methodSource
def StreamReducer.result() -> TranscriptionResultBuild the reduced transcription result.
TranscriptionResultclass:
TranscriptionResultfrom the committed segments.TranscriptionResultOrdered by
startwhen every retained segment carries oneTranscriptionResult(
measuredandstart_onlyalike -- a real onset is a realTranscriptionResulttime position); otherwise the ledger's reading order is used
TranscriptionResult(declaration order with supersede replacements IN PLACE), and
TranscriptionResulttextjoins segment texts in the same list order eitherTranscriptionResultway. When any retained segment lacks a full measured span
TranscriptionResult(
timestamp_status != "measured"), the result carries theTranscriptionResultaggregate
segment_timestamps_unavailablewarning diagnostic;TranscriptionResultthe per-segment truth is the nullable
start/endvaluesTranscriptionResultthemselves (consumers such as the SRT/VTT renderers read those,
TranscriptionResultnever a marker, and never value-sniff). Suppression diagnostics
TranscriptionResultrecorded by
add(order-integrity violations of a rawTranscriptionResultnon-compliant stream) are included first.
TranscriptionResultsegmentsis ALWAYS a list --[]for an empty sessionTranscriptionResult(silence, a fresh reducer, or every segment retired by pure
TranscriptionResultdeletion), never
None: the reducer performed the segmentTranscriptionResultlifecycle, so emptiness is the spec's "requested but empty"
TranscriptionResultstate, and the renderers' null rule (
None+ non-empty textTranscriptionResultsynthesizes a whole-text fallback cue;
[]renders zeroTranscriptionResultcues) depends on the distinction being kept on the wire.
SyncSession#classSource
class SyncSession
SyncSession(
session: TranscriptionSession,
*,
submit_timeout: float | None = 30.0,
)Synchronous bridge over an async TranscriptionSession.
Runs a single background event loop in a dedicated thread (owned by this object and torn down on close), so applications can drive an async engine synchronously and authors only ever write async code.
Lifecycle submits (__enter__ / input calls / __exit__) carry a
timeout: a hanging engine _open / _close can never deadlock the
calling thread, and on every cooperative path the background loop + thread
are torn down even on timeout (from an external thread, no deadlock). A
truly blocking, non-awaiting engine can survive the join and leave the
daemon thread alive with the loop unclosed -- see _shutdown; the
sync-bridge compliance check reports that state as a thread leak.
Wrap an async session.
| Name | Type | Description |
|---|---|---|
session | TranscriptionSession | The async session to drive. |
submit_timeout= 30.0 | float | None | Seconds to wait for a lifecycle submit (enter /
feed / send / end / exit) before raising |
__enter__#methodSource
def SyncSession.__enter__() -> SyncSessionEnter the async session's context.
Exception-safe: a context manager whose __enter__ raises never
receives __exit__, so a failed enter MUST tear down the owned loop +
thread started in __init__ itself -- otherwise an engine whose
_open raises (bad credentials, unreachable host) would leak the
bridge's background thread (no leak). The timeout path is
already torn down inside _submit; the entered guard also
covers a non-timeout raise (_open raising a regular exception).
SyncSessionThe sync session.
TimeoutErrorIf the engine
_openhangs pastsubmit_timeout.
__exit__#methodSource
def SyncSession.__exit__(*exc: object) -> NoneExit the async context and stop the owned loop.
feed#methodSource
def SyncSession.feed(source: Iterable[bytes] | bytes | bytearray) -> NoneFeed audio from a managed source.
| Name | Type | Description |
|---|---|---|
source | Iterable[bytes] | bytes | bytearray | A sync iterable of byte chunks, or a single |
TypeErrorIf
sourceis astr(forwarded from the async session: astris byte-chunks-shaped only by accident -- a whole file goes tostart_transcription(audio=...)).InvalidSessionUseErrorIf manual input or a prior feed was already used (forwarded from the async session).
StreamClosedErrorIf the bridge was already torn down (its
withblock exited, or a prior lifecycle call timed out).TimeoutErrorIf the call does not complete within the submit timeout; the bridge is torn down first (no-hang contract).
send_audio#methodSource
def SyncSession.send_audio(chunk: bytes) -> NoneManually send one audio chunk.
| Name | Type | Description |
|---|---|---|
chunk | bytes | The audio chunk. |
InvalidSessionUseErrorIf
feedwas already used (forwarded from the async session: mixing input modes).StreamClosedErrorIf the input was ended or the session already delivered a terminal event (forwarded from the async session), or the bridge was already torn down (its
withblock exited, or a prior lifecycle call timed out).TimeoutErrorIf the call does not complete within the submit timeout; the bridge is torn down first (no-hang contract).
end_audio#methodSource
def SyncSession.end_audio() -> NoneMark the end of manual audio input.
InvalidSessionUseErrorIf
feedwas already used (forwarded from the async session: mixing input modes).StreamClosedErrorIf the bridge was already torn down (its
withblock exited, or a prior lifecycle call timed out).TimeoutErrorIf the call does not complete within the submit timeout; the bridge is torn down first (no-hang contract).
__iter__#methodSource
def SyncSession.__iter__() -> Iterator[TranscriptionEvent]Iterate events synchronously.
Event waits are unbounded by design: a live, fed session may legitimately go arbitrarily long between events (user silence), so the pump must never manufacture a timeout for it -- a stuck pipeline already surfaces as a terminal event from the async side's own deadlines. The pump waits in short slices purely to detect the two failures those in-loop deadlines cannot report: the owned event-loop thread dying, and the loop being frozen by an engine running blocking (non-async) code. Brief blocking stalls are tolerated (several consecutive unresponsive probes are required), so only a persistently frozen loop tears the bridge down.
TranscriptionEventEvents from the underlying async session.
StreamClosedErrorIf the bridge was already torn down (its
withblock exited, or a prior lifecycle call timed out), or if the session was never entered (no event stream exists to pump).TimeoutErrorIf the owned event-loop thread died or stayed unresponsive, so no further event (or in-loop deadline) can ever be delivered.
result#methodSource
def SyncSession.result() -> TranscriptionResultReduce the session so far into a transcription result.
SERIALIZED WITH THE PRODUCER while the bridge is live: the reduction
walks reducer state the producer task mutates (a supersede pops
segments mid-walk), so it is submitted to the owned loop like every
other bridge member -- asyncio's run-to-completion between awaits is
the mutual exclusion, and the wrapper coroutine never awaits around
the call. Running it on the caller's thread instead read that state
concurrently: a rendering loop (for ev in sync: render(sync. result())) crashed with a spurious KeyError or returned a torn
result mixing pre- and post-supersede segments. After teardown
(__exit__, or a timed-out lifecycle call) no producer runs
anymore, so the direct call is safe -- and keeps the
result-after-the-with-block pattern working.
TranscriptionResultThe reduced result.
TimeoutErrorIf the live loop cannot run the reduction within the submit timeout (frozen by blocking engine code); the bridge is torn down first (no-hang contract).
diagnostics#methodSource
def SyncSession.diagnostics() -> list[Diagnostic]Return the session's standard-layer and lifecycle diagnostics.
Mirrors TranscriptionSession.diagnostics so a synchronously-
driven session exposes the same parameter-gating / language-resolution
/ lifecycle-suppression diagnostics as the async one. Without this the
sync bridge would silently drop a first-class, compliance-checked part
of the session surface (the sync bridge is a faithful mirror of the
async session). Serialized with the producer exactly like
result (the snapshot copies guard state the producer task
appends to); direct once torn down.
list[Diagnostic]The accumulated diagnostics.
TimeoutErrorIf the live loop cannot run the snapshot within the submit timeout (frozen by blocking engine code); the bridge is torn down first (no-hang contract).
is_loop_alive#methodSource
def SyncSession.is_loop_alive() -> boolWhether the owned background event-loop thread is still running.
The bridge starts a dedicated event-loop thread in __init__ and MUST
tear it down on close (__exit__) or on a failed __enter__ (from
an external thread). After a clean lifecycle this
returns False; a True here once the session is closed is a leaked
loop thread. Exposed so the sync-bridge compliance check can assert on
this bridge's own thread rather than diffing the whole process thread
set (which would mis-flag a dependency's benign daemon thread as a leak).
boolTrueif the owned loop thread is alive.
TranscriptionEvent#classSource
class TranscriptionEvent(BaseModel)A single streaming transcription event.
| Name | Type | Description |
|---|---|---|
type | EventType | The event type. |
segment_id= None | str | None | Stable id of the segment this event concerns. |
text= None | str | None | The segment's complete current text (cumulative/replace). |
stable_until= None | int | None | Frozen-prefix length in codepoints (monotonic per
segment while recognition is in progress; a terminal |
finality= 'final' | Literal['final', 'closed'] | For |
words= None | list[Word] | None | Optional word-level detail (shares the batch |
speaker= None | str | None | Segment-level speaker label, with the same
inheritance rule as |
start= None | float | Nonege=0.0 | Segment start time in seconds (origin = first session sample). |
end= None | float | Nonege=0.0 | Segment end time in seconds. |
audio_processed_until= None | float | Nonege=0.0 | Monotonic audio-time cursor in seconds. |
old_ids= list() | list[str] | For |
new_ids= list() | list[str] | For |
code= None | str | None | For |
recoverable= None | bool | None | For |
retriable_after= None | float | Nonege=0.0 | For |
reconnect= None | bool | None | For |
gap_start= None | float | Nonege=0.0 | For a reconnect |
gap_end= None | float | Nonege=0.0 | For a reconnect |
detected_language= None | str | None | The engine-detected language (BCP-47, never
|
extra= dict() | WireExtra | Engine-specific extra data. |
stable_text | str | The frozen prefix of Guards against an invalid (negative or out-of-range) |
is_content | bool | Whether this event advances transcription content. |
is_terminal | bool | Whether this event ends the session. |
partial#class methodSource
@classmethod
def TranscriptionEvent.partial(
segment_id: str,
text: str,
**kw: Any,
) -> TranscriptionEventBuild a partial event.
| Name | Type | Description |
|---|---|---|
segment_id | str | The segment id. |
text | str | The segment's complete current text. |
**kw= {} | Any | Additional event fields. |
TranscriptionEventA
partialevent.
final#class methodSource
@classmethod
def TranscriptionEvent.final(
segment_id: str,
text: str,
**kw: Any,
) -> TranscriptionEventBuild a final event.
| Name | Type | Description |
|---|---|---|
segment_id | str | The segment id. |
text | str | The segment's final text. |
**kw= {} | Any | Additional event fields. |
TranscriptionEventA
finalevent.
closed#class methodSource
@classmethod
def TranscriptionEvent.closed(
segment_id: str,
text: str,
**kw: Any,
) -> TranscriptionEventBuild a closed finality event (a final with finality=closed).
| Name | Type | Description |
|---|---|---|
segment_id | str | The segment id. |
text | str | The segment's possibly post-processed text. |
**kw= {} | Any | Additional event fields. |
TranscriptionEventA
finalevent markedfinality="closed".
supersede#class methodSource
@classmethod
def TranscriptionEvent.supersede(
old_ids: list[str],
new_ids: list[str],
**kw: Any,
) -> TranscriptionEventBuild a supersede event replacing old segments with new ones.
Lineage is set-to-set: old_ids/new_ids express the
cardinality of the re-segmentation (which ids retire, which appear)
but not a per-old->per-new mapping. On a merge+split (many->many) a UI
cannot tell which specific old segment a given new segment descends
from. This is a documented v1 limitation; the spec does not
require a pairwise mapping, and the frozen-prefix-preservation invariant
is enforced over the concatenated prefixes, not per pair. Per-pair
edit-ops/diffs are a possible future direction (additive later).
| Name | Type | Description |
|---|---|---|
old_ids | list[str] | The retired segment ids, in reading (time) order. |
new_ids | list[str] | The replacement segment ids, in reading (time) order
(must be disjoint from |
**kw= {} | Any | Additional event fields. |
TranscriptionEventA
supersedeevent.
ValueErrorIf
old_idsis empty (a supersede MUST retire at least one segment), ifold_idsandnew_idsintersect, or if either list repeats a segment id.
progress#class methodSource
@classmethod
def TranscriptionEvent.progress(**kw: Any) -> TranscriptionEventBuild a progress event (heartbeat / cursor / reconnect notice).
| Name | Type | Description |
|---|---|---|
**kw= {} | Any | Event fields (for example, |
TranscriptionEventA
progressevent.
done#class methodSource
@classmethod
def TranscriptionEvent.done(**kw: Any) -> TranscriptionEventBuild a terminal done event.
| Name | Type | Description |
|---|---|---|
**kw= {} | Any | Additional event fields. |
TranscriptionEventA
doneevent.
make_error#class methodSource
@classmethod
def TranscriptionEvent.make_error(
code: str,
*,
recoverable: bool = False,
**kw: Any,
) -> TranscriptionEventBuild an error event.
| Name | Type | Description |
|---|---|---|
code | str | The error code. |
recoverable= False | bool | Whether the session may continue. |
**kw= {} | Any | Additional event fields. |
TranscriptionEventAn
errorevent.
TranscriptionSession#classSource
class TranscriptionSession(ABC)
TranscriptionSession(
*,
done_timeout: float | None = DEFAULT_DONE_TIMEOUT,
max_idle: float | None = DEFAULT_MAX_IDLE,
max_session_seconds: float | None = DEFAULT_MAX_SESSION_SECONDS,
event_buffer_capacity: int = DEFAULT_EVENT_BUFFER_CAPACITY,
audio_queue_maxsize: int = DEFAULT_AUDIO_QUEUE_MAXSIZE,
audio_history_maxlen: int = DEFAULT_AUDIO_HISTORY_MAXLEN,
strict_lifecycle: bool = False,
max_guard_diagnostics: int = DEFAULT_MAX_GUARD_DIAGNOSTICS,
)Async-first, full-duplex streaming session base.
Authors implement _produce (and optionally _open /
_close), reading fed audio via audio_chunks and yielding
TranscriptionEvent objects. The base manages input ownership,
bounded backpressure, lifecycle enforcement, a bounded rolling audio buffer
- reconnect scaffolding, termination deadlines, and result reduction.
Reconnect contract -- base vs engine:
- The base owns a bounded rolling audio buffer (the most recent fed
chunks) and exposes
replay_buffer, the sourcereplayableclassification, andnote_reconnect. It cannot detect a reconnect itself (it owns no network connection). - The engine detects the disconnect, re-establishes the connection,
replays
replay_bufferaudio, keepssegment_id/ timestamps / detected language / speaker-label mapping continuous, then callsnote_reconnect. For speaker labels the safe default is mint-fresh: the engine MUST NOT reuse a pre-reconnect label for a post-reconnect cluster without identity evidence (blind clustering restarts from zero after a reconnect) -- it MUST mint fresh labels (speaker_2,speaker_3, and so on) and emit aspeaker_labels_resetdiagnostic viaemit_diagnostic-- the fidelity-warning counterpart ofcontent_lost, which is an error event -- because over-counting speakers is the safe direction while silently merging two people under one label is not. The base then emits theprogress(reconnect=True, gap_start, gap_end)event and, iff the engine passedcontent_lost=True(its own determination that the reconnect + replay could not cover the gap), a trailingerror(code="content_lost", recoverable=True)fidelity warning.
Initialize the session.
| Name | Type | Description |
|---|---|---|
done_timeout= DEFAULT_DONE_TIMEOUT | float | None | Seconds of total pipeline inactivity -- no event
arriving AND no fed audio consumed via |
max_idle= DEFAULT_MAX_IDLE | float | None | Seconds without a content event ( |
max_session_seconds= DEFAULT_MAX_SESSION_SECONDS | float | None | Absolute wall-clock cap; |
event_buffer_capacity= DEFAULT_EVENT_BUFFER_CAPACITY | int | Pending-event budget shared by every
event kind -- drop-proof |
audio_queue_maxsize= DEFAULT_AUDIO_QUEUE_MAXSIZE | int | Max pending audio chunks; bounds |
audio_history_maxlen= DEFAULT_AUDIO_HISTORY_MAXLEN | int | Capacity of the bounded rolling audio buffer used to replay recent audio after a reconnect. |
strict_lifecycle= False | bool | If |
max_guard_diagnostics= DEFAULT_MAX_GUARD_DIAGNOSTICS | int | Cap on the bounded lifecycle-suppression
diagnostics channel; further diagnostics are
aggregated into a single overflow summary rather than growing
without bound. Exposed alongside the other bounds so a session
can size it; defaults to |
ValueErrorIf a deadline is not positive (or
Nonewhere allowed) or a buffer/queue bound is not positive. In particularaudio_queue_maxsize=0would mean an UNBOUNDEDasyncio.Queue-- silently disabling the documented feed backpressure -- so it is rejected rather than passed through. Also ifmax_guard_diagnosticsis not positive.
| Name | Type | Description |
|---|---|---|
replayable | bool | Whether the audio source can be re-read after a reconnect. |
done_timeout | float | None | The configured pipeline-inactivity backstop in seconds. |
max_idle | float | None | The configured content-stall deadline in seconds. |
max_session_seconds | float | None | The configured absolute wall-clock cap in seconds. |
audio_chunks#async methodSource
async def TranscriptionSession.audio_chunks() -> AsyncIterator[bytes]Async-iterate fed audio chunks until the input ends.
Each yielded chunk is also retained in the bounded rolling audio buffer for possible replay after a reconnect.
Consuming from this iterator is the session's liveness anchor: every
dequeue resets the done_timeout backstop, so an
engine that legitimately emits no events during user silence stays
alive for as long as it keeps reading audio here.
AsyncIterator[bytes]Raw audio chunks in the session's declared format.
emit_diagnostic#methodSource
def TranscriptionSession.emit_diagnostic(
*,
code: str,
message: str,
level: Literal['info', 'warning'] = 'info',
param: str | None = None,
provided: JsonValue | BaseModel = None,
effective: JsonValue | BaseModel = None,
) -> NoneSurface a structured diagnostic from _produce (the streaming channel).
The streaming counterpart of the batch path's result.diagnostics: call
this from your _produce to report a non-fatal note -- a best-effort
degradation, an assumed parameter, a lossy fallback -- through the session's
own diagnostics channel, so a WS client (mid-stream diagnostics
frame) or a sync caller (diagnostics()) sees WHY. For
a fatal condition, emit an error event instead -- this is for
non-fatal notes that must not end the stream.
Recorded through the same bounded channel as the guard's own diagnostics: past the cap entries aggregate into the overflow summary rather than growing without bound, so a chatty producer cannot exhaust memory.
Security -- this is engine-authored, client-facing output, like the
transcript text itself. Every field (message / param / provided
/ effective) is forwarded to clients VERBATIM -- to a (possibly
unauthenticated) WebSocket client as a diagnostics frame, and, for the
batch counterpart result.diagnostics, in the REST response -- and is
NOT redacted. The standard layer scrubs only the detail IT auto-captures
(an error event's extra, which holds a scrubbed exception
summary -- never a raw str(exc)); content you
pass here is your contract to keep. NEVER pass a credential, API key, a URL
with embedded auth, or raw exception text -- route sensitive operator detail
to logging instead. (That asymmetry is deliberate: an error event's
extra is auto-captured so the server drops it; this note you chose, so
you own its safety.)
| Name | Type | Description |
|---|---|---|
code | str | Stable, machine-readable diagnostic code (for example, |
message | str | Human-readable explanation (client-facing; no secrets). |
level= 'info' | Literal['info', 'warning'] |
|
param= None | str | None | The parameter the diagnostic concerns, if any. |
provided= None | JsonValue | BaseModel | The value the application provided, if relevant
(client-facing). A pydantic model is projected into its JSON
form via |
effective= None | JsonValue | BaseModel | The value that took effect, if relevant
(client-facing; projected like |
pydantic.ValidationErrorIf
provided/effectivehas no JSON form even after projection (for example, an arbitrary in-process object) -- an engine bug surfaced loudly at the call site, naming the field, instead of failing later in the transport. Raising from_produceterminates the session, so keep such objects in your own state (see the docs' wire-visible values section).
replay_buffer#methodSource
def TranscriptionSession.replay_buffer() -> list[bytes]Return audio for re-feeding to a freshly re-established connection.
Engines call this on reconnect to re-send audio. For a replayable (list / tuple) source the COMPLETE source is returned -- replayability promises loss-free replay, so it must not be silently truncated to the rolling ring. For a non-replayable / live source only the bounded rolling window of recent chunks is available (older audio was evicted).
list[bytes]The audio chunks to replay, oldest first: the full source if
list[bytes]replayable, otherwise the bounded rolling window.
note_reconnect#methodSource
def TranscriptionSession.note_reconnect(
gap_start: float | None = None,
gap_end: float | None = None,
*,
content_lost: bool = False,
) -> NoneRecord that an internal reconnect bridged a gap (engine-driven).
The base ALWAYS emits a progress(reconnect=True, gap_start, gap_end)
event in order with produced events, followed -- IMMEDIATELY -- by a
trailing error(code="content_lost", recoverable=True, gap_start, gap_end) IFF the engine passes content_lost=True.
The events are flushed promptly, the moment this is called, through
the drop-proof path (_CoalescingBuffer.put_forced, the same sync
primitive the base uses for synthesized terminals). This matters because
an engine typically calls note_reconnect and then BLOCKS while
it re-establishes the connection without yielding another event; deferring
the flush to the next produced event would leave the consumer staring at a
timeout/silence during a slow reconnect -- the opposite of "transparent
but honest". note_reconnect runs in the
producer-task coroutine on the session loop, so a synchronous flush is
correct and ordered (the events land after already-emitted events and
before any subsequent one). The _run_producer / finally drains
remain as a harmless safety net -- they simply find nothing pending.
Content loss is an explicit engine determination, not something the
base infers from rolling-buffer eviction: a live ring is always evicting,
so eviction is the wrong signal (it would falsely claim permanent loss on
every long live session). The engine -- which alone knows whether its
reconnect + replay_buffer replay actually covered the gap -- sets
content_lost=True only when audio the engine had not yet processed
could not be replayed and is therefore truly lost. This mirrors the
existing contract where segment_id / timestamps / detected language /
speaker-label mapping continuity across the reconnect is likewise the
engine's responsibility (the base never rewrites them). For speaker
labels the engine MUST NOT reuse a pre-reconnect label without identity
evidence; the safe default is to mint fresh labels and emit a
speaker_labels_reset diagnostic via emit_diagnostic (the
fidelity-warning counterpart of the content_lost error event).
| Name | Type | Description |
|---|---|---|
gap_start= None | float | None | Start time (seconds) of the lossy gap, if known. |
gap_end= None | float | None | End time (seconds) of the lossy gap, if known. |
content_lost= False | bool |
|
feed#methodSource
def TranscriptionSession.feed(
source: Iterable[bytes] | AsyncIterable[bytes] | bytes | bytearray,
) -> NoneFeed audio from a managed source (mutually exclusive with manual).
A bare bytes / bytearray is treated as a single audio chunk
(not an iterable of chunks -- iterating it would yield int byte
values). A non-async, re-iterable collection (list / tuple, or a
wrapped bytes-like) is classified as replayable for reconnect
purposes; an async source or a one-shot generator/iterator is
non-replayable. Any AsyncIterable (the Pythonic __aiter__
protocol, not only the stricter AsyncIterator) is accepted and
normalized via aiter.
| Name | Type | Description |
|---|---|---|
source | Iterable[bytes] | AsyncIterable[bytes] | bytes | bytearray | A sync or async iterable of byte chunks ( |
TypeErrorIf
sourceis astr. AstrsatisfiesIterable[str]and would be silently consumed one character at a time (or fail deep inside an engine as a confusingengine_error); passing a file path here is a common slip. A whole audio file goes tostart_transcription(audio=...)and incremental input is raw PCM byte chunks.InvalidSessionUseErrorIf manual input was already used (mixing) or
feedwas already called once -- a usage error against a still-live session, NOT a lifecycle close.TypeErrorIf a subclass rebound a reserved base attribute.
send_audio#async methodSource
async def TranscriptionSession.send_audio(chunk: bytes) -> NoneManually send one audio chunk (mutually exclusive with feed).
Manual sources are always treated as non-replayable (live input).
| Name | Type | Description |
|---|---|---|
chunk | bytes | The audio chunk. |
InvalidSessionUseErrorIf
feedwas already used (mixing input modes is a usage error against a still-live session).StreamClosedErrorIf the input was ended (
end_audio()) or the session already delivered a terminal event -- a genuine lifecycle close: the audio queue has no consumer anymore, so raising beats blocking forever on a dead queue.TypeErrorIf a subclass rebound a reserved base attribute.
end_audio#async methodSource
async def TranscriptionSession.end_audio() -> NoneMark the end of manual audio input (idempotent in manual mode).
Claims manual ownership if this is the first input call, so a later
feed is correctly rejected as mixing.
InvalidSessionUseErrorIf
feedwas used (mixing input modes is a usage error against a still-live session).TypeErrorIf a subclass rebound a reserved base attribute.
__aenter__#async methodSource
async def TranscriptionSession.__aenter__() -> TranscriptionSessionOpen resources and start the producer.
Anchors the max_session_seconds wall-clock origin here, at session
establishment, so the absolute cap measures from when the session
opened -- not from the first __anext__ -- and a re-entered
iterator cannot reset it.
TranscriptionSessionThe session.
TypeErrorIf a subclass rebound a reserved base attribute.
__aexit__#async methodSource
async def TranscriptionSession.__aexit__(*exc: object) -> NoneTear down the producer, feed task, and engine resources.
Cancels the producer and feed tasks and AWAITS them (so no coroutine is
still touching engine state when _close runs), then closes.
__aiter__#methodSource
def TranscriptionSession.__aiter__() -> AsyncIterator[TranscriptionEvent]Return the event async iterator (single-consumer).
A session has exactly ONE event stream and exactly ONE consumer: the
events live in a shared bounded buffer, and a second concurrent
iterator would race the first for _CoalescingBuffer.get,
silently splitting the stream between them (each iterator would see an
arbitrary subset, so neither's result would match the stream --
breaking the stream == result invariant). Re-iterating would also reset
the per-iteration deadline anchors. Both are programming errors, so the
second call fails loudly instead of returning a competing iterator.
AsyncIterator[TranscriptionEvent]The session's event async iterator.
InvalidSessionUseErrorIf the session is already being iterated -- a usage error against a still-live session, not a lifecycle close.
TypeErrorIf a subclass rebound a reserved base attribute.
diagnostics#methodSource
def TranscriptionSession.diagnostics() -> list[Diagnostic]Return the diagnostics accumulated for this session so far.
The lifecycle-suppression channel is bounded: a
misbehaving engine that trips a clamp on (nearly) every event cannot
grow this list without limit over a long session. Once
DEFAULT_MAX_GUARD_DIAGNOSTICS entries accumulate, further
suppressions are aggregated into a single trailing
diagnostics_truncated summary (per-code counts) instead of being
retained individually -- so the overflow is reported honestly, never
silently dropped.
list[Diagnostic]The standard-layer parameter-gating / language diagnostics attached
list[Diagnostic]at session establishment, followed by the runtime's
list[Diagnostic]lifecycle-suppression diagnostics (suppressed illegal transitions or
list[Diagnostic]clamped
stable_untilvalues), capped as described above.
result#methodSource
def TranscriptionSession.result() -> TranscriptionResultReduce the session so far into a transcription result.
TranscriptionResultThe reduced result.
reduce_event#functionSource
def reduce_event(
order: list[str],
texts: dict[str, str],
event: TranscriptionEvent,
) -> NoneApply the canonical streaming reduce (the spec §5.2 reference).
This is the core reduce every compliant application implements --
including the supersede handling -- mirroring the specification's
snippet line for line. The state is a reading-order list plus a text
map, NOT a bare map: for untimestamped streams the spec's ordering rule
is "list order IS reading order", and a mid-stream supersede must
splice its replacements into the retired block's position -- a plain
dict can only append, which silently reorders the transcript (the
cardinal sin). Display text is texts joined in order
(" ".join(texts[sid] for sid in order if sid in texts) for
space-delimited languages). Non-text events are ignored.
This helper assumes a lifecycle-legal stream, which is what
TranscriptionSession delivers (its guard suppresses illegal
events before they reach the application). Driving it with a raw
non-compliant stream fails loudly instead of corrupting the order.
| Name | Type | Description |
|---|---|---|
order | list[str] | The mutable reading-order list of live segment ids, updated in place (first mention claims the next position; a supersede splices). |
texts | dict[str, str] | The mutable |
event | TranscriptionEvent | The event to apply. |
ValueErrorIf a
supersederetires nothing at all (emptyold_ids-- only a validator-bypassing construction can produce one), retires an id that holds no live position, listsold_idsnon-contiguously or out of reading order, or reintroduces an id that already holds one.
validate_stable_until#functionSource
def validate_stable_until(text: str, stable_until: int) -> boolReturn whether stable_until is a valid frozen-prefix boundary.
stable_until is a codepoint count; text[:stable_until] is the frozen
prefix. It MUST NOT split a Unicode combining sequence -- that is, the codepoint
at the cut (if any) must not be a combining mark. Validated
with stdlib unicodedata only.
| Name | Type | Description |
|---|---|---|
text | str | The segment text. |
stable_until | int | The proposed frozen-prefix length in codepoints. |
boolTrueif the boundary is valid.