Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 thread
hiroshihorie marked this conversation as resolved.
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')
;
Comment thread
hiroshihorie marked this conversation as resolved.
Outdated

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