Standard ASR

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 stable segment_id, cumulative text, a conservative stable_until codepoint frontier, and an audio_processed_until cursor.
  • validate_stable_until -- enforces the combining-character invariant (stable_until MUST NOT split a combining sequence) using stdlib unicodedata only.
  • reduce_event / StreamReducer -- the canonical application-side reduce (including the core supersede handling) and reduction of a session to a TranscriptionResult.
  • TranscriptionSession -- an async-first, full-duplex session base. Authors implement the async _open / _produce / _close hooks; the base provides feed vs manual send_audio / end_audio single 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 = 256

DEFAULT_AUDIO_QUEUE_MAXSIZE#attributeSource

DEFAULT_AUDIO_QUEUE_MAXSIZE = 256

DEFAULT_DONE_TIMEOUT#attributeSource

DEFAULT_DONE_TIMEOUT: float | None = 300.0

DEFAULT_EVENT_BUFFER_CAPACITY#attributeSource

DEFAULT_EVENT_BUFFER_CAPACITY = 1024

DEFAULT_MAX_GUARD_DIAGNOSTICS#attributeSource

DEFAULT_MAX_GUARD_DIAGNOSTICS = 1000

DEFAULT_MAX_IDLE#attributeSource

DEFAULT_MAX_IDLE: float | None = None

DEFAULT_MAX_SESSION_SECONDS#attributeSource

DEFAULT_MAX_SESSION_SECONDS: float | None = None

DIAG_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.

Attributes
NameTypeDescription
done_timeout
= DEFAULT_DONE_TIMEOUT
float | None
gt=0.0

The pipeline-inactivity hang backstop, reset by events AND by audio consumption.

max_idle
= DEFAULT_MAX_IDLE
float | None
gt=0.0

The opt-in content-stall detector.

max_session_seconds
= DEFAULT_MAX_SESSION_SECONDS
float | None
gt=0.0

The opt-in absolute wall-clock cap.

StreamReducer#classSource

class StreamReducer

Reduces 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.

Attributes
NameTypeDescription
detected_languagestr | None

The sticky session-level detected language reduced so far.

The last non-None detected_language carried by an ADMITTED event (a suppressed event never commits one). This is the same value result reports; exposed separately so a caller holding a live reducer (for example, the session's terminal-event funnel) can read it without building a full intermediate result.

add#methodSource

def StreamReducer.add(event: TranscriptionEvent) -> None

Incorporate 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.

Parameters
NameTypeDescription
eventTranscriptionEvent

The event to incorporate.

result#methodSource

def StreamReducer.result() -> TranscriptionResult

Build the reduced transcription result.

Returns

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.

Parameters
NameTypeDescription
sessionTranscriptionSession

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 TimeoutError and tearing the loop down. None waits forever (not recommended).

__enter__#methodSource

def SyncSession.__enter__() -> SyncSession

Enter 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).

Returns
Raises
  • TimeoutError

    If the engine _open hangs past submit_timeout.

__exit__#methodSource

def SyncSession.__exit__(*exc: object) -> None

Exit the async context and stop the owned loop.

feed#methodSource

def SyncSession.feed(source: Iterable[bytes] | bytes | bytearray) -> None

Feed audio from a managed source.

Parameters
NameTypeDescription
sourceIterable[bytes] | bytes | bytearray

A sync iterable of byte chunks, or a single bytes / bytearray chunk.

Raises
  • TypeError

    If source is a str (forwarded from the async session: a str is byte-chunks-shaped only by accident -- a whole file goes to start_transcription(audio=...)).

  • InvalidSessionUseError

    If manual input or a prior feed was already used (forwarded from the async session).

  • StreamClosedError

    If the bridge was already torn down (its with block exited, or a prior lifecycle call timed out).

  • TimeoutError

    If 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) -> None

Manually send one audio chunk.

Parameters
NameTypeDescription
chunkbytes

The audio chunk.

Raises
  • InvalidSessionUseError

    If feed was already used (forwarded from the async session: mixing input modes).

  • StreamClosedError

    If 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 with block exited, or a prior lifecycle call timed out).

  • TimeoutError

    If 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() -> None

Mark the end of manual audio input.

Raises
  • InvalidSessionUseError

    If feed was already used (forwarded from the async session: mixing input modes).

  • StreamClosedError

    If the bridge was already torn down (its with block exited, or a prior lifecycle call timed out).

  • TimeoutError

    If 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.

Yields
Raises
  • StreamClosedError

    If the bridge was already torn down (its with block exited, or a prior lifecycle call timed out), or if the session was never entered (no event stream exists to pump).

  • TimeoutError

    If 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() -> TranscriptionResult

Reduce 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.

Returns
Raises
  • TimeoutError

    If 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.

Returns
Raises
  • TimeoutError

    If 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() -> bool

Whether 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).

Returns
  • bool

    True if the owned loop thread is alive.

TranscriptionEvent#classSource

class TranscriptionEvent(BaseModel)

A single streaming transcription event.

Attributes
NameTypeDescription
typeEventType

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 closed restatement may shrink it).

finality
= 'final'
Literal['final', 'closed']

For final events, "final" or "closed" (a closed final is the terminal restatement and may rewrite frozen text once).

words
= None
list[Word] | None

Optional word-level detail (shares the batch Word model).

speaker
= None
str | None

Segment-level speaker label, with the same inheritance rule as Segment.speaker (a non-None words[i].speaker overrides it at word level) and the same label-validity rule. A frozen segment's accepted speaker is protected across events by the lifecycle guard, not at construction.

start
= None
float | None
ge=0.0

Segment start time in seconds (origin = first session sample).

end
= None
float | None
ge=0.0

Segment end time in seconds.

audio_processed_until
= None
float | None
ge=0.0

Monotonic audio-time cursor in seconds.

old_ids
= list()
list[str]

For supersede, the retired segment ids.

new_ids
= list()
list[str]

For supersede, the replacement segment ids.

code
= None
str | None

For error, the error code.

recoverable
= None
bool | None

For error, whether the session may continue.

retriable_after
= None
float | None
ge=0.0

For error, suggested retry delay in seconds.

reconnect
= None
bool | None

For progress, whether this marks a reconnect.

gap_start
= None
float | None
ge=0.0

For a reconnect progress, the gap start time.

gap_end
= None
float | None
ge=0.0

For a reconnect progress, the gap end time.

detected_language
= None
str | None

The engine-detected language (BCP-47, never auto), validated like TranscriptionResult.detected_language; sticky per session (the last non-None value wins), and the reconnect-continuity carrier.

extra
= dict()
WireExtra

Engine-specific extra data.

stable_textstr

The frozen prefix of text (text[:stable_until]).

Guards against an invalid (negative or out-of-range) stable_until so a malformed frontier never produces a wrong or oversized prefix.

is_contentbool

Whether this event advances transcription content.

is_terminalbool

Whether this event ends the session.

partial#class methodSource

@classmethod
def TranscriptionEvent.partial(
    segment_id: str,
    text: str,
    **kw: Any,
) -> TranscriptionEvent

Build a partial event.

Parameters
NameTypeDescription
segment_idstr

The segment id.

textstr

The segment's complete current text.

**kw
= {}
Any

Additional event fields.

Returns

final#class methodSource

@classmethod
def TranscriptionEvent.final(
    segment_id: str,
    text: str,
    **kw: Any,
) -> TranscriptionEvent

Build a final event.

Parameters
NameTypeDescription
segment_idstr

The segment id.

textstr

The segment's final text.

**kw
= {}
Any

Additional event fields.

Returns

closed#class methodSource

@classmethod
def TranscriptionEvent.closed(
    segment_id: str,
    text: str,
    **kw: Any,
) -> TranscriptionEvent

Build a closed finality event (a final with finality=closed).

Parameters
NameTypeDescription
segment_idstr

The segment id.

textstr

The segment's possibly post-processed text.

**kw
= {}
Any

Additional event fields.

Returns

supersede#class methodSource

@classmethod
def TranscriptionEvent.supersede(
    old_ids: list[str],
    new_ids: list[str],
    **kw: Any,
) -> TranscriptionEvent

Build 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).

Parameters
NameTypeDescription
old_idslist[str]

The retired segment ids, in reading (time) order.

new_idslist[str]

The replacement segment ids, in reading (time) order (must be disjoint from old_ids).

**kw
= {}
Any

Additional event fields.

Returns
Raises
  • ValueError

    If old_ids is empty (a supersede MUST retire at least one segment), if old_ids and new_ids intersect, or if either list repeats a segment id.

progress#class methodSource

@classmethod
def TranscriptionEvent.progress(**kw: Any) -> TranscriptionEvent

Build a progress event (heartbeat / cursor / reconnect notice).

Parameters
NameTypeDescription
**kw
= {}
Any

Event fields (for example, audio_processed_until, reconnect).

Returns

done#class methodSource

@classmethod
def TranscriptionEvent.done(**kw: Any) -> TranscriptionEvent

Build a terminal done event.

Parameters
NameTypeDescription
**kw
= {}
Any

Additional event fields.

Returns

make_error#class methodSource

@classmethod
def TranscriptionEvent.make_error(
    code: str,
    *,
    recoverable: bool = False,
    **kw: Any,
) -> TranscriptionEvent

Build an error event.

Parameters
NameTypeDescription
codestr

The error code.

recoverable
= False
bool

Whether the session may continue.

**kw
= {}
Any

Additional event fields.

Returns

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 source replayable classification, and note_reconnect. It cannot detect a reconnect itself (it owns no network connection).
  • The engine detects the disconnect, re-establishes the connection, replays replay_buffer audio, keeps segment_id / timestamps / detected language / speaker-label mapping continuous, then calls note_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 a speaker_labels_reset diagnostic via emit_diagnostic -- the fidelity-warning counterpart of content_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 the progress(reconnect=True, gap_start, gap_end) event and, iff the engine passed content_lost=True (its own determination that the reconnect + replay could not cover the gap), a trailing error(code="content_lost", recoverable=True) fidelity warning.

Initialize the session.

Parameters
NameTypeDescription
done_timeout
= DEFAULT_DONE_TIMEOUT
float | None

Seconds of total pipeline inactivity -- no event arriving AND no fed audio consumed via audio_chunks -- before synthesizing a done_timeout error. A hang backstop, not engine-liveness detection: a silently listening engine stays alive while it keeps consuming audio. After end_audio() it bounds the engine's flush-and-done window. None disables (explicit opt-out of the backstop).

max_idle
= DEFAULT_MAX_IDLE
float | None

Seconds without a content event (partial / final / supersede) before force-terminating with a stream_stalled error. NOT reset by progress heartbeats or audio consumption, so it detects an engine that consumes (or chats) without ever producing content. None (the default) disables: silence is a normal state for a live session.

max_session_seconds
= DEFAULT_MAX_SESSION_SECONDS
float | None

Absolute wall-clock cap; None disables.

event_buffer_capacity
= DEFAULT_EVENT_BUFFER_CAPACITY
int

Pending-event budget shared by every event kind -- drop-proof final / supersede / error / done slots consume it too, and a not-yet-delivered segment can hold two slots at once (its declaration partial plus its final). A new-segment partial or progress heartbeat arriving once the budget is spent overflows, which the session reports as a terminal backpressure error.

audio_queue_maxsize
= DEFAULT_AUDIO_QUEUE_MAXSIZE
int

Max pending audio chunks; bounds feed / send_audio so a slow engine exerts real backpressure.

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 True, raise on illegal lifecycle transitions instead of suppressing + diagnosing them.

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 DEFAULT_MAX_GUARD_DIAGNOSTICS.

Raises
  • ValueError

    If a deadline is not positive (or None where allowed) or a buffer/queue bound is not positive. In particular audio_queue_maxsize=0 would mean an UNBOUNDED asyncio.Queue -- silently disabling the documented feed backpressure -- so it is rejected rather than passed through. Also if max_guard_diagnostics is not positive.

Attributes
NameTypeDescription
replayablebool

Whether the audio source can be re-read after a reconnect.

done_timeoutfloat | None

The configured pipeline-inactivity backstop in seconds.

max_idlefloat | None

The configured content-stall deadline in seconds.

max_session_secondsfloat | 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.

Yields
  • 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,
) -> None

Surface 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.)

Parameters
NameTypeDescription
codestr

Stable, machine-readable diagnostic code (for example, "vad_fallback").

messagestr

Human-readable explanation (client-facing; no secrets).

level
= 'info'
Literal['info', 'warning']

"info" or "warning" (default "info").

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 to_json_value -- the same projection the standard's own diagnostics apply -- so passing a request submodel just works instead of raising out of a live _produce.

effective
= None
JsonValue | BaseModel

The value that took effect, if relevant (client-facing; projected like provided).

Raises
  • pydantic.ValidationError

    If provided / effective has 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 _produce terminates 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).

Returns
  • 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,
) -> None

Record 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).

Parameters
NameTypeDescription
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

True if the reconnect could not cover the gap and unreplayable audio was permanently lost; queues a non-terminal content_lost fidelity warning after the progress.

feed#methodSource

def TranscriptionSession.feed(
    source: Iterable[bytes] | AsyncIterable[bytes] | bytes | bytearray,
) -> None

Feed 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.

Parameters
NameTypeDescription
sourceIterable[bytes] | AsyncIterable[bytes] | bytes | bytearray

A sync or async iterable of byte chunks (Iterable / AsyncIterable of bytes), or a single bytes / bytearray chunk.

Raises
  • TypeError

    If source is a str. A str satisfies Iterable[str] and would be silently consumed one character at a time (or fail deep inside an engine as a confusing engine_error); passing a file path here is a common slip. A whole audio file goes to start_transcription(audio=...) and incremental input is raw PCM byte chunks.

  • InvalidSessionUseError

    If manual input was already used (mixing) or feed was already called once -- a usage error against a still-live session, NOT a lifecycle close.

  • TypeError

    If a subclass rebound a reserved base attribute.

send_audio#async methodSource

async def TranscriptionSession.send_audio(chunk: bytes) -> None

Manually send one audio chunk (mutually exclusive with feed).

Manual sources are always treated as non-replayable (live input).

Parameters
NameTypeDescription
chunkbytes

The audio chunk.

Raises
  • InvalidSessionUseError

    If feed was already used (mixing input modes is a usage error against a still-live session).

  • StreamClosedError

    If 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.

  • TypeError

    If a subclass rebound a reserved base attribute.

end_audio#async methodSource

async def TranscriptionSession.end_audio() -> None

Mark 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.

Raises
  • InvalidSessionUseError

    If feed was used (mixing input modes is a usage error against a still-live session).

  • TypeError

    If a subclass rebound a reserved base attribute.

__aenter__#async methodSource

async def TranscriptionSession.__aenter__() -> TranscriptionSession

Open 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.

Returns
Raises
  • TypeError

    If a subclass rebound a reserved base attribute.

__aexit__#async methodSource

async def TranscriptionSession.__aexit__(*exc: object) -> None

Tear 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.

Returns
Raises
  • InvalidSessionUseError

    If the session is already being iterated -- a usage error against a still-live session, not a lifecycle close.

  • TypeError

    If 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.

Returns
  • 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_until values), capped as described above.

result#methodSource

def TranscriptionSession.result() -> TranscriptionResult

Reduce the session so far into a transcription result.

Returns

reduce_event#functionSource

def reduce_event(
    order: list[str],
    texts: dict[str, str],
    event: TranscriptionEvent,
) -> None

Apply 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.

Parameters
NameTypeDescription
orderlist[str]

The mutable reading-order list of live segment ids, updated in place (first mention claims the next position; a supersede splices).

textsdict[str, str]

The mutable {segment_id: text} map, updated in place.

eventTranscriptionEvent

The event to apply.

Raises
  • ValueError

    If a supersede retires nothing at all (empty old_ids -- only a validator-bypassing construction can produce one), retires an id that holds no live position, lists old_ids non-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) -> bool

Return 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.

Parameters
NameTypeDescription
textstr

The segment text.

stable_untilint

The proposed frozen-prefix length in codepoints.

Returns
  • bool

    True if the boundary is valid.

On this page