Skip to content
Draft
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
1 change: 1 addition & 0 deletions .changes/raise-minimum-sdk-flutter-338
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
major type="changed" "Raise the minimum SDK to Flutter 3.38 / Dart 3.10, required for Native Assets"
1 change: 1 addition & 0 deletions .changes/uniffi-rust-core-dev-wiring
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="added" "Wire in the livekit_uniffi Rust core behind a native-only facade, delivered as a bundled cdylib via Native Assets"
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ CI (`build.yaml`) runs all of the above plus example-app builds for every platfo

Web/native divergence is handled with conditional imports (e.g. `track/processor_native.dart` vs `processor_web.dart`) — new platform-specific code should follow that pattern.

## The Rust core (`livekit_uniffi`)

`lib/src/uniffi/` wraps `livekit_uniffi`, a Dart package generated from the `livekit-uniffi` crate in the sibling `rust-sdks` repo. It reaches Rust through Dart's Native Assets: the package's `hook/build.dart` bundles a `cdylib` into the host app and the generated bindings call into it with `@Native`. This is why the SDK requires Flutter >= 3.38 / Dart >= 3.10.

There is no dynamic library to load on the web, so `uniffi.dart` splits native/web the same way the rest of the SDK does. **`uniffi_io.dart` is the only file allowed to import `package:livekit_uniffi/...`** — importing it from anywhere reachable on web pulls `dart:ffi` into a web compile and breaks `flutter build web`/`--wasm`. Guard calls with `LiveKitUniffi.isAvailable`.

### Local development loop

`livekit_uniffi` is not on pub.dev yet, so both `pubspec.yaml` and `example/pubspec.yaml` override it to a path in a sibling `rust-sdks` checkout (overrides don't propagate from a dependency, hence both). To produce or refresh it:

```sh
cd ../rust-sdks/livekit-uniffi
cargo make dart-package # generates packages/dart/: bindings, pubspec, build hook, host cdylib
cd -
flutter pub get
flutter test test/uniffi/ # smoke test: calls buildVersion() across the FFI boundary
```

Requires `cargo-make`, `protoc` and `tera`. Re-run `cargo make dart-package` whenever the crate's exported surface changes — the build hook tracks the copied library, so a stale one won't be silently reused.

Two things to know about that hook: it picks the locally built library purely on target *OS*, not architecture, so a host build can be bundled into an iOS or Android build by mistake — verify the desktop target first when debugging. And its download mode (used when no local library is present) fetches `build-<triple>.zip` from a `livekit-uniffi` GitHub release; no release currently carries those assets, so download mode fails until the `cdylib` job is re-enabled in `rust-sdks`.

## Common pitfalls (from issue history)

- `flutter_webrtc` is pinned to an exact version on purpose: livekit_client and flutter_webrtc must agree on the same WebRTC-SDK native pods, and mismatches break user builds (CocoaPods conflicts). Bump it only in sync with a matching WebRTC-SDK version.
Expand Down
4 changes: 3 additions & 1 deletion analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ analyzer:
avoid_print: ignore
deprecated_member_use_from_same_package: ignore

# Exclude protobuf files
# Exclude generated files: protobuf, and json_serializable output. Neither is
# hand-edited, so lint hits there can only be fixed by changing the generator.
exclude:
- "**/*.pb.dart"
- "**/*.pbenum.dart"
- "**/*.pbjson.dart"
- "**/*.pbserver.dart"
- "**/*.g.dart"
# - 'web/*.dart'
# Xcode vendors Swift package checkouts under build when Swift Package
# Manager is enabled and this package ships Package.swift.
Expand Down
4 changes: 4 additions & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ dependency_overrides:
# Pin packages that use Apple APIs unavailable on CI runner's Xcode version.
connectivity_plus: '>=7.0.0 <7.1.0' # NWPath.isUltraConstrained
device_info_plus: '>=12.3.0 <12.4.0' # NSProcessInfo.isiOSAppOnVision
# Overrides do not propagate from a dependency, so livekit_client's override of
# livekit_uniffi has to be repeated here. Drop both once it is on pub.dev.
livekit_uniffi:
path: ../../rust-sdks/livekit-uniffi/packages/dart

dev_dependencies:
flutter_test:
Expand Down
12 changes: 7 additions & 5 deletions lib/src/agent/agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ class Agent extends ChangeNotifier {
final RemoteAudioTrack? nextAudioTrack = _resolveAudioTrack(participant);
final RemoteVideoTrack? nextAvatarTrack = _resolveAvatarVideoTrack(participant);

final bool shouldNotify = _state != _AgentLifecycle.connected ||
final bool shouldNotify =
_state != _AgentLifecycle.connected ||
_agentState != nextAgentState ||
!identical(_audioTrack, nextAudioTrack) ||
!identical(_avatarVideoTrack, nextAvatarTrack) ||
Expand Down Expand Up @@ -216,13 +217,14 @@ enum AgentFailure {
timeout,

/// The agent left the room unexpectedly.
left;
left
;

/// A human-readable error message.
String get message => switch (this) {
AgentFailure.timeout => 'Agent did not connect',
AgentFailure.left => 'Agent left the room unexpectedly',
};
AgentFailure.timeout => 'Agent did not connect',
AgentFailure.left => 'Agent left the room unexpectedly',
};
}

enum _AgentLifecycle {
Expand Down
11 changes: 6 additions & 5 deletions lib/src/agent/chat/transcription_stream_receiver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ class TranscriptionStreamReceiver implements MessageReceiver {
this.topic = 'lk.transcription',
void Function(String topic, TextStreamHandler handler)? registerHandler,
void Function(String topic)? unregisterHandler,
}) : _room = room,
_registerHandler = registerHandler ?? room.registerTextStreamHandler,
_unregisterHandler = unregisterHandler ?? room.unregisterTextStreamHandler;
}) : _room = room,
_registerHandler = registerHandler ?? room.registerTextStreamHandler,
_unregisterHandler = unregisterHandler ?? room.unregisterTextStreamHandler;

final Room _room;
final String topic;
Expand Down Expand Up @@ -217,8 +217,9 @@ class TranscriptionStreamReceiver implements MessageReceiver {
final displayTimestamp = partial?.timestamp ?? timestamp;
final isLocalParticipant = _room.localParticipant?.identity == participantIdentity;

final ReceivedMessageContent content =
isLocalParticipant ? UserTranscript(displayContent) : AgentTranscript(displayContent);
final ReceivedMessageContent content = isLocalParticipant
? UserTranscript(displayContent)
: AgentTranscript(displayContent);

return ReceivedMessage(
Comment on lines 219 to 224

@1egoman 1egoman Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Image

Per the comment in the pull request body: the vast majority of this change is mechanical and can be ignored. In practice, it's more like a couple hundred lines of interesting stuff. Here's an example of the reformatting noise which is scoped to 5074c77.

id: segmentId,
Expand Down
16 changes: 8 additions & 8 deletions lib/src/agent/room_agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ extension AgentRoom on Room {
/// participants whose `lk.publish_on_behalf` attribute matches the agent's
/// identity.
Iterable<RemoteParticipant> get agentParticipants => remoteParticipants.values.where(
(participant) {
if (participant.kind != ParticipantKind.AGENT) {
return false;
}
final publishOnBehalf = participant.attributes[lkPublishOnBehalfAttributeKey];
return publishOnBehalf == null || publishOnBehalf.isEmpty;
},
);
(participant) {
if (participant.kind != ParticipantKind.AGENT) {
return false;
}
final publishOnBehalf = participant.attributes[lkPublishOnBehalfAttributeKey];
return publishOnBehalf == null || publishOnBehalf.isEmpty;
},
);

/// The first agent participant in the room, if one exists.
RemoteParticipant? get agentParticipant => agentParticipants.firstOrNull;
Expand Down
24 changes: 13 additions & 11 deletions lib/src/agent/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ class Session extends DisposableChangeNotifier {
required SessionOptions options,
List<MessageSender>? senders,
List<MessageReceiver>? receivers,
}) : _tokenSourceConfiguration = tokenSourceConfiguration,
_options = options,
room = options.room {
}) : _tokenSourceConfiguration = tokenSourceConfiguration,
_options = options,
room = options.room {
_agent.addListener(notifyListeners);

final textMessageSender = TextMessageSender(room: room);
Expand Down Expand Up @@ -160,9 +160,9 @@ class Session extends DisposableChangeNotifier {
ConnectionState _connectionState = ConnectionState.disconnected;

bool get isConnected => switch (_connectionState) {
ConnectionState.connecting || ConnectionState.connected || ConnectionState.reconnecting => true,
ConnectionState.disconnected => false,
};
ConnectionState.connecting || ConnectionState.connected || ConnectionState.reconnecting => true,
ConnectionState.disconnected => false,
};

final LinkedHashMap<String, ReceivedMessage> _messages = LinkedHashMap();
UnmodifiableListView<ReceivedMessage> _messagesView = UnmodifiableListView<ReceivedMessage>(const []);
Expand Down Expand Up @@ -285,7 +285,9 @@ class Session extends DisposableChangeNotifier {
_messages
..clear()
..addEntries(
messages.sorted((a, b) => a.timestamp.compareTo(b.timestamp)).map(
messages
.sorted((a, b) => a.timestamp.compareTo(b.timestamp))
.map(
(message) => MapEntry(message.id, message),
),
);
Expand Down Expand Up @@ -407,10 +409,10 @@ class SessionError {
final Object cause;

String get message => switch (kind) {
SessionErrorKind.connection => 'Connection failed: ${cause}',
SessionErrorKind.sender => 'Message sender failed: ${cause}',
SessionErrorKind.receiver => 'Message receiver failed: ${cause}',
};
SessionErrorKind.connection => 'Connection failed: ${cause}',
SessionErrorKind.sender => 'Message sender failed: ${cause}',
SessionErrorKind.receiver => 'Message receiver failed: ${cause}',
};

static SessionError connection(Object cause) => SessionError._(SessionErrorKind.connection, cause);

Expand Down
3 changes: 2 additions & 1 deletion lib/src/audio/audio_frame_capture.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import 'audio_frame_capture_native.dart' if (dart.library.js_interop) 'audio_fra
/// PCM sample format for audio frame capture.
enum AudioFormat {
Int16('int16'),
Float32('float32');
Float32('float32')
;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

curiously, what these format changes coming from ?

are they from some tools ?

I wonder if we can move these format refactoring to a separate PR instead


final String value;
const AudioFormat(this.value);
Expand Down
14 changes: 8 additions & 6 deletions lib/src/audio/audio_frame_capture_native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ class AudioFrameCaptureNative implements AudioFrameCapture {
_streamSubscription = _eventChannel?.receiveBroadcastStream().listen((event) {
try {
final rawFormat = event['commonFormat'] as String?;
_controller.add(AudioFrame(
sampleRate: event['sampleRate'] as int,
channels: event['channels'] as int,
data: event['data'] as Uint8List,
format: rawFormat == AudioFormat.Float32.value ? AudioFormat.Float32 : AudioFormat.Int16,
));
_controller.add(
AudioFrame(
sampleRate: event['sampleRate'] as int,
channels: event['channels'] as int,
data: event['data'] as Uint8List,
format: rawFormat == AudioFormat.Float32.value ? AudioFormat.Float32 : AudioFormat.Int16,
),
);
} catch (e) {
logger.warning('[AudioFrameCapture] Error parsing native event: $e');
}
Expand Down
14 changes: 8 additions & 6 deletions lib/src/audio/audio_frame_capture_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,14 @@ class AudioFrameCaptureWeb implements AudioFrameCapture {
bytes = float32ToInt16Bytes(srcFloat32, channels, outChannels, frames);
}

controller.add(AudioFrame(
sampleRate: actualSampleRate,
channels: outChannels,
data: bytes,
format: _targetFormat,
));
controller.add(
AudioFrame(
sampleRate: actualSampleRate,
channels: outChannels,
data: bytes,
format: _targetFormat,
),
);
} catch (e) {
logger.warning('[AudioFrameCapture] Error processing worklet frame: $e');
}
Expand Down
10 changes: 5 additions & 5 deletions lib/src/audio/audio_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,11 @@ class AudioManager {
}

ResolvedAudioSessionPolicy _resolvedAudioSessionPolicy(AudioSessionOptions options) => ResolvedAudioSessionPolicy(
options: options,
preferSpeakerOutput: _preferSpeakerOutput,
forceSpeakerOutput: _forceSpeakerOutput && _preferSpeakerOutput,
automatic: _isAutomaticConfigurationEnabled,
);
options: options,
preferSpeakerOutput: _preferSpeakerOutput,
forceSpeakerOutput: _forceSpeakerOutput && _preferSpeakerOutput,
automatic: _isAutomaticConfigurationEnabled,
);

/// How microphone input is muted on iOS/macOS.
///
Expand Down
50 changes: 24 additions & 26 deletions lib/src/audio/audio_processing_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ enum AudioProcessingImplementation {
disabled('disabled'),
software('software'),
platform('platform'),
softwareAndPlatform('softwareAndPlatform');
softwareAndPlatform('softwareAndPlatform')
;

const AudioProcessingImplementation(this.value);

final String value;

static AudioProcessingImplementation fromValue(String? value) => AudioProcessingImplementation.values.firstWhere(
(e) => e.value == value,
orElse: () => AudioProcessingImplementation.unknown,
);
(e) => e.value == value,
orElse: () => AudioProcessingImplementation.unknown,
);
}

AudioProcessingMode _modeFromValue(String? value) {
Expand All @@ -56,9 +57,9 @@ class AudioProcessingComponentRequest {
});

factory AudioProcessingComponentRequest.fromMap(Map<dynamic, dynamic> map) => AudioProcessingComponentRequest(
enabled: (map['enabled'] as bool?) ?? false,
mode: _modeFromValue(map['mode'] as String?),
);
enabled: (map['enabled'] as bool?) ?? false,
mode: _modeFromValue(map['mode'] as String?),
);

final bool enabled;
final AudioProcessingMode mode;
Expand All @@ -84,16 +85,16 @@ class AudioProcessingComponentState {
});

factory AudioProcessingComponentState.fromMap(Map<dynamic, dynamic> map) => AudioProcessingComponentState(
requested: map['requested'] is Map
? AudioProcessingComponentRequest.fromMap(Map<dynamic, dynamic>.from(map['requested'] as Map))
: null,
isSoftwareResolved: (map['isSoftwareResolved'] as bool?) ?? false,
isSoftwareActive: (map['isSoftwareActive'] as bool?) ?? false,
isPlatformAvailable: (map['isPlatformAvailable'] as bool?) ?? false,
isPlatformResolved: (map['isPlatformResolved'] as bool?) ?? false,
isPlatformActive: (map['isPlatformActive'] as bool?) ?? false,
effective: AudioProcessingImplementation.fromValue(map['effective'] as String?),
);
requested: map['requested'] is Map
? AudioProcessingComponentRequest.fromMap(Map<dynamic, dynamic>.from(map['requested'] as Map))
: null,
isSoftwareResolved: (map['isSoftwareResolved'] as bool?) ?? false,
isSoftwareActive: (map['isSoftwareActive'] as bool?) ?? false,
isPlatformAvailable: (map['isPlatformAvailable'] as bool?) ?? false,
isPlatformResolved: (map['isPlatformResolved'] as bool?) ?? false,
isPlatformActive: (map['isPlatformActive'] as bool?) ?? false,
effective: AudioProcessingImplementation.fromValue(map['effective'] as String?),
);

/// What the caller most recently requested for this component. Null when no
/// audio processing options have ever been applied — "nobody asked".
Expand Down Expand Up @@ -141,15 +142,12 @@ class AudioProcessingState {
});

factory AudioProcessingState.fromMap(Map<dynamic, dynamic> map) => AudioProcessingState(
hasAudioProcessingModule: (map['hasAudioProcessingModule'] as bool?) ?? false,
echoCancellation:
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['echoCancellation'] as Map)),
noiseSuppression:
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['noiseSuppression'] as Map)),
autoGainControl:
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['autoGainControl'] as Map)),
highPassFilter: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['highPassFilter'] as Map)),
);
hasAudioProcessingModule: (map['hasAudioProcessingModule'] as bool?) ?? false,
echoCancellation: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['echoCancellation'] as Map)),
noiseSuppression: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['noiseSuppression'] as Map)),
autoGainControl: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['autoGainControl'] as Map)),
highPassFilter: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['highPassFilter'] as Map)),
);

final bool hasAudioProcessingModule;
final AudioProcessingComponentState echoCancellation;
Expand Down
Loading
Loading