Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/ci-budget.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,24 @@
},
"runs_on": "ubuntu-latest"
},
"sdk-dart.yml/dart": {
"budget_p50_minutes": 5,
"display_name": "dart",
"expected_runs_class": "unsampled-pr-sentinel",
"expected_runs_per_day": 1,
"if": null,
"matrix": {},
"matrix_size": 1,
"needs": [],
"owner": "CI maintainers",
"rationale": "2026-10-19 new path-scoped job: two PRs touched sdks/device/dart since 2026-07-30; 5 minutes covers cached Flutter 3.44.5 setup, pub get and the package tests",
"reusable": null,
"runner": "linux",
"runner_counts": {
"linux": 1
},
"runs_on": "ubuntu-latest"
},
"sdk-rust.yml/rust": {
"budget_p50_minutes": 5,
"display_name": "rust",
Expand Down Expand Up @@ -4872,6 +4890,28 @@
}
}
},
"sdk-dart.yml": {
"name": "Dart Device SDK",
"owner": "CI maintainers",
"rationale": "2026-10-19: path-scoped like sdk-cpp.yml; no workflow or manifest lane ran the Dart device SDK tests, and the Hygiene runner has no Flutter toolchain",
"triggers": {
"pull_request": {
"paths": [
"sdks/device/dart/**",
".github/workflows/sdk-dart.yml"
]
},
"push": {
"branches": [
"main"
],
"paths": [
"sdks/device/dart/**",
".github/workflows/sdk-dart.yml"
]
}
}
},
"sdk-rust.yml": {
"name": "Rust SDK",
"owner": "CI maintainers",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"schema_version": 1,
"id": "FC-readiness-gate-swallows-terminal-close",
"violated_contract": "A streaming client that holds back writes until the server's ready handshake must report a close that arrives before that handshake. The Go device SDK's Parakeet transcriber returned nil from AppendPCM whenever ready had not arrived and never checked whether its read loop had already ended. When the server accepted the WebSocket and then closed it with 1013 capacity_full or service_not_ready (backend/parakeet/main.py), every PCM chunk of the session was dropped while AppendPCM and Stop both returned nil.",
"canonical_prevention": "The read loop records the error that ended it before signalling completion, and the pre-ready write path checks that signal. A stream whose read loop ended without ready returns the recorded close, with its code still unwrappable, from every write and from Stop. A stream still waiting for ready keeps the documented drop-until-ready behaviour. A loopback test closes the stream before ready and asserts that both calls return that close.",
"canonical_prevention_artifact": [
"sdks/device/go/omidevice/stt/parakeet_test.go"
],
"evidence_prs": [],
"scope_hints": [
"sdks/device/go/omidevice/stt/"
],
"status": "open"
}
34 changes: 34 additions & 0 deletions .github/workflows/sdk-dart.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Dart Device SDK

on:
pull_request:
paths:
- "sdks/device/dart/**"
- ".github/workflows/sdk-dart.yml"
push:
branches: [main]
paths:
- "sdks/device/dart/**"
- ".github/workflows/sdk-dart.yml"

permissions:
contents: read

jobs:
dart:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: sdks/device/dart
steps:
- uses: actions/checkout@v7
# The Hygiene manifest runner has no Flutter toolchain, so this Flutter
# package runs its own suite, like sdk-cpp.yml and sdk-rust.yml.
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- run: flutter pub get
- run: flutter test
10 changes: 10 additions & 0 deletions sdks/device/dart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ Note: the production Omi app uses **native Pigeon BLE** for lifecycle. This pack
## STT

See `sdks/device/STT.md`.

`ParakeetTranscriber` drops PCM until the server sends `ready`. If the server closes the stream before
`ready` (for example 1013 `capacity_full` or `service_not_ready`), `appendPcm` and `stop` throw an
`SttStreamClosedException` carrying the close code and reason, so callers can retry or switch engines.

## Tests

Run `flutter test` in this directory. The STT tests drive the transcribers through an in-memory WebSocket
channel, so the suite needs no device, credentials, or transcription service. CI runs it for changes under
`sdks/device/dart/` (`.github/workflows/sdk-dart.yml`).
53 changes: 47 additions & 6 deletions sdks/device/dart/lib/stt/stt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ abstract class StreamingTranscriber {
Future<void> stop();
}

/// Thrown by [ParakeetTranscriber.appendPcm] and [ParakeetTranscriber.stop] when the server closed
/// the stream before sending `ready`, e.g. 1013 `capacity_full` or `service_not_ready`. That stream
/// never becomes ready, so callers can retry or switch engines instead of losing the session's audio.
class SttStreamClosedException implements Exception {
const SttStreamClosedException({this.closeCode, this.closeReason, this.error});

/// The server's WebSocket close code, e.g. 1013 (try again later).
final int? closeCode;

/// The server's close reason, e.g. `capacity_full`.
final String? closeReason;

/// The stream error that ended the connection, when it failed without a close frame.
final Object? error;

@override
String toString() => 'SttStreamClosedException: stream closed before ready: ${error ?? '$closeCode $closeReason'}';
}

String deepgramWsUrl({int sampleRate = 16000}) {
return 'wss://api.deepgram.com/v1/listen?punctuate=true&model=nova&language=en-US'
'&encoding=linear16&sample_rate=$sampleRate&channels=1';
Expand All @@ -37,7 +56,8 @@ class DeepgramTranscriber implements StreamingTranscriber {
this.drainTimeout = const Duration(seconds: 5),
WebSocketChannel? channel,
}) {
_channel = channel ??
_channel =
channel ??
IOWebSocketChannel.connect(
Uri.parse(deepgramWsUrl(sampleRate: sampleRate)),
headers: {'Authorization': 'Token $apiKey'},
Expand Down Expand Up @@ -105,8 +125,7 @@ class ParakeetTranscriber implements StreamingTranscriber {
this.drainTimeout = const Duration(seconds: 5),
WebSocketChannel? channel,
}) {
_channel = channel ??
WebSocketChannel.connect(Uri.parse(parakeetWsUrl(apiUrl, sampleRate: sampleRate)));
_channel = channel ?? WebSocketChannel.connect(Uri.parse(parakeetWsUrl(apiUrl, sampleRate: sampleRate)));
_sub = _channel.stream.listen(
(event) {
if (event is! String) return;
Expand All @@ -120,8 +139,8 @@ class ParakeetTranscriber implements StreamingTranscriber {
if (text != null && text.isNotEmpty) onTranscript(text);
} catch (_) {}
},
onDone: _markDrained,
onError: (_) => _markDrained(),
onDone: _streamEnded,
onError: (Object error) => _streamEnded(error),
);
}

Expand All @@ -132,12 +151,26 @@ class ParakeetTranscriber implements StreamingTranscriber {
StreamSubscription? _sub;
bool _ready = false;
bool _stopped = false;
SttStreamClosedException? _closedBeforeReady;
final Completer<void> _drained = Completer<void>();

void _markDrained() {
if (!_drained.isCompleted) _drained.complete();
}

// The server can end the stream before ready, e.g. 1013 capacity_full or service_not_ready
// (backend/parakeet/main.py). Such a stream never becomes ready, so keep why it ended.
void _streamEnded([Object? error]) {
if (!_ready && !_stopped) {
_closedBeforeReady ??= SttStreamClosedException(
closeCode: _channel.closeCode,
closeReason: _channel.closeReason,
error: error,
);
}
_markDrained();
}

static String? _extract(Map<String, dynamic> data) {
final t = data['text'] ?? data['transcript'];
if (t is String && t.isNotEmpty) return t;
Expand All @@ -146,7 +179,13 @@ class ParakeetTranscriber implements StreamingTranscriber {

@override
void appendPcm(Uint8List chunk) {
if (_stopped || !_ready) return;
if (_stopped) return;
if (!_ready) {
// PCM before ready is dropped, unless the server already ended the stream.
final closed = _closedBeforeReady;
if (closed != null) throw closed;
return;
}
_channel.sink.add(chunk);
}

Expand All @@ -169,6 +208,8 @@ class ParakeetTranscriber implements StreamingTranscriber {
try {
await _channel.sink.close();
} catch (_) {}
final closed = _closedBeforeReady;
if (closed != null) throw closed;
}
}

Expand Down
Loading
Loading