Skip to content

feat(dart): add streaming transcription provider contract - #55

Merged
harshsaver merged 2 commits into
october-dev:mainfrom
Pushkraj-Space:issue-7-providers-define-a-streaming-transcripti
Sep 30, 2026
Merged

harshsaver merged 2 commits into
october-dev:mainfrom
Pushkraj-Space:issue-7-providers-define-a-streaming-transcripti

Conversation

@Pushkraj-Space

Copy link
Copy Markdown
Contributor

Summary

Issue #7 needs a provider-neutral streaming transcription boundary that works with audio from any Murmur connector. It must not expose a vendor SDK, and it needs a deterministic fake provider for coordinator tests. This PR supplies the provider seam for the capture coordinator (#36).

This change:

  • adds VoiceProvider, ProviderSession, ProviderSessionRequest, ProviderCapability, and a sealed ProviderEvent family to murmur_protocol
  • specifies ordering, accumulation, and revision for partial, final, and rejected transcripts
  • separates graceful finalize from the hard-cancel stop, with a state matrix for every operation after start, finalize, stop, or failure
  • declares optional capabilities instead of requiring adapters to simulate them
  • documents failures, rate limits, close, reconnect, backpressure, buffering, and the credential boundary in the public dartdoc
  • adds a manually driven test-support fake that covers every fault the issue lists
  • rewrites the VoiceProvider section of docs/voice-runtime.md and updates the package README and CHANGELOG

Contract

  • Creation: createSession(request) is synchronous, performs no I/O, and emits nothing. The caller subscribes to events before start(). An undecodable AudioFormat throws VoiceError(code: "unsupported_format", retryable: false).
  • Session metadata: ProviderSessionRequest holds only sessionId and format. It deliberately has no capture mode, VoiceSource, metadata map, or credential field.
  • Transcripts: delivery order is the session's total order.
    • A ProviderPartial is the complete, replaceable hypothesis of the open segment, and '' clears it.
    • A ProviderFinal commits the segment. Finals are never revised and include their own separators, so an utterance is the exact concatenation of its finals (CJK and punctuation stay correct). Empty finals are invalid; "nothing said" is zero finals.
    • A ProviderRejected commits and discards the segment and never enters assembly.
    • Text arrives only on events. finalize() and mute() return Future<void>.
  • Finalize vs. stop:
    • finalize() closes the gate synchronously, lets accepted audio finish decoding, emits the remaining finals, and then closes. It is single-flight and terminal.
    • stop() closes the gate, discards unaccepted audio, completes every pending operation with cancelled, adds no new events, and never throws. Repeated and concurrent calls share one future.
  • Capabilities: immediateMute, warmUnmute (requires immediateMute), realAudioReadiness, amplitude, and rejectedFinals.
    • An uncapable mute/unmute throws UnsupportedError.
    • Adapters must not emit undeclared optional events.
    • Mute, unmute, and finalize are serialized: repeating the pending call shares its future, and a conflicting call throws StateError.
  • Failures:
    • A startup failure is reported only by start().
    • A later failure emits one ProviderFailure, followed by stream done, and every pending operation completes with that error.
    • Documented codes are cancelled, unsupported_format, unauthorized, rate_limited (optional retryAfterMs), and provider_disconnected.
    • Sessions never reconnect. A retry is a new session, and finals already emitted stay valid.
  • Backpressure and buffering:
    • The caller owns a bounded pre-open pre-roll.
    • At most one addFrame is outstanding, and its future is the backpressure signal.
    • Adapters bound and document their queue. An empty queue always admits a valid frame, and a frame over the documented maximum throws ArgumentError synchronously.
    • Frames offered while muted or finalizing are discarded.
  • Credentials: credentials enter only through adapter constructors or factories. They never appear in requests, events, errors, or logs, and the contract performs no logging.

Fake provider

test/support/fake_voice_provider.dart uses no timers and is driven entirely by the test. By default it enforces every rule above.

Issue fault Control
Delay startup holdStart, completeStart, failStart
Partial and final chunks emitPartial, emitFinal (also while mute or finalize is pending)
Finalize late or empty leave finalize pending, or call completeFinalize() with no finals
Reject a final rejectFinal
Fail or disconnect fail(error), disconnect()
Ignore cancellation incorrectly ignoreCancellation: true (off by default)
Backpressure holdFrames, releaseFrame

The fake records the terminal state before it notifies listeners, so a synchronous listener that reenters the session sees the ended session. Driver callbacks that arrive after the session ends add nothing.

The fake lives in test support next to the future coordinator tests. No public testing library is published until a second package needs it.

Tests

test/provider_test.dart adds 59 tests in 11 groups:

  • values
  • request, format, and startup
  • partials
  • multi-final assembly
  • finalization
  • duplicate finalize
  • cancellation
  • late events
  • failure and disconnect
  • capabilities and the input gate
  • backpressure and validity

Together they cover partials, multi-final assembly, finalization, duplicate finalize, cancellation, late events, and failure. They also cover these edge cases:

  • stop cancelling a pending start or overtaking a pending finalize
  • a held frame that belongs to the flushed tail
  • cleanup with no listener or a paused listener
  • events queued for a paused listener before a stop
  • listeners that reenter the session on failure or on done
  • identical terminal futures after stop and after failures

Audit

The first audit found five issues, and all five are fixed:

  • failure-listener reentrancy
  • reentrant stop() future identity
  • terminal finalize() future identity
  • missing buffering guarantees in the dartdoc
  • invalid late callbacks that threw

For fixes 1–3, I checked that reverting each fix makes its new regression test fail. The re-audit passed with no findings.

Scope

Validation

  • make check-dart equivalent in dart:stable (Dart 3.13.3): format clean, analyzer clean, and all 78 tests pass (59 new, 19 existing)
  • python3 tool/check_conformance.py: consistent (15 sets, 44 lines)
  • git diff --check
  • make check-protocol did not run because protoc is not available locally. No proto file changed.

Closes #7

🤖 Generated with Claude Code

Define a provider-neutral boundary between Murmur audio and speech
recognition, plus a deterministic fake provider for coordinator tests.
It supplies the provider seam used by the capture coordinator (october-dev#36).

Contract (lib/src/provider.dart, exported from murmur_protocol):
- VoiceProvider exposes a stable id, fixed capabilities, and a synchronous,
  inert createSession(ProviderSessionRequest), so the caller subscribes
  before start and cannot miss startup events. An unsupported format
  throws VoiceError(unsupported_format).
- ProviderSessionRequest carries only a session id and AudioFormat. It has
  no credentials, capture mode, device source, or metadata map. Providers
  stay mode-neutral, and credentials enter only through adapter config.
- ProviderSession: events, start, addFrame, mute, unmute, finalize, stop.
  Sessions are one-shot and never reconnect; a retry is a new session.
- Events: partials replace the open hypothesis, finals are append-only
  verbatim fragments whose concatenation is the utterance, and rejected
  segments are never assembled. Readiness, amplitude, and a terminal
  ProviderFailure complete the set. Text lives only on the event stream.
- finalize is graceful: the gate closes synchronously, accepted audio
  is decoded, and the remaining finals are emitted. stop is a hard cancel
  that settles pending work with cancelled. A state matrix specifies
  every operation after stop, failure, or finalize.
- Optional capabilities: immediateMute, warmUnmute (requires
  immediateMute), realAudioReadiness, amplitude, and rejectedFinals.
  Mute, unmute, and finalize are serialized and single-flight.
- The caller owns pre-open buffering. At most one addFrame is
  outstanding. Adapter queues are bounded and documented. An empty queue
  always admits a valid frame, and an oversized frame throws.
- Documented error codes: cancelled, unsupported_format, unauthorized,
  rate_limited (retryAfterMs), and provider_disconnected.

Test support (test/support/fake_voice_provider.dart) is a manually
driven fake with no timers. It enforces the contract by default and can
delay or fail startup, emit partials, finals, and rejections, finalize
late or empty, fail, disconnect, and ignore cancellation. The terminal
state is recorded before listeners are notified, so reentrant callbacks
see the ended session.

Rewrite the VoiceProvider section of docs/voice-runtime.md and update the
package README and CHANGELOG. No proto, fixture, dependency, or other-SDK
changes.

Refs october-dev#7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@harshsaver harshsaver left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a thorough contract and it covers every #7 criterion. Local: dart format/analyze clean, 78/78 tests (59 new). It reuses VoiceError and the snake_case codes from runtime.dart, adds no dependencies, and keeps the fake in test/support. Merging — and agreed with the plan in the description: #51 rebases onto this provider.dart and fake.

Non-blocking follow-ups:

  1. docs/voice-runtime.md:163 still says "empty finals are valid outcomes and do not become actions", but ProviderFinal('') now throws and "nothing said" is zero finals. Please reword that bullet so the two sections agree.
  2. The endpointing section mentions muted(flushing: false), but mute() (provider.dart:288) always flushes. Either note that stop covers the non-flushing case, or drop that doc line.
  3. fake_voice_provider.dart:296 marks the stop-induced cancelled error as retryable: true. Worth one sentence in the contract saying whether cancelled is retryable, so adapters agree.
  4. Cross-PR consistency with #56 (graceful finalization as baseline vs. a capability; one error-code registry) — I've asked for that on #56 since it's rebasing anyway.

# Conflicts:
#	sdks/dart/murmur_protocol/CHANGELOG.md
@harshsaver
harshsaver merged commit 19bc2e5 into october-dev:main Sep 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Providers] Define a streaming transcription contract and fake provider

2 participants