From 87bf36287062265c8f668b6a994f86ab2340bff3 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 14 Jul 2026 15:23:57 -0700 Subject: [PATCH 1/2] init adding of realtime input config for live api --- .../firebase_ai/lib/firebase_ai.dart | 7 +- .../firebase_ai/lib/src/live_api.dart | 188 +++++++++++++++++- .../firebase_ai/lib/src/live_session.dart | 38 +++- .../firebase_ai/test/live_session_test.dart | 58 +++++- .../firebase_ai/test/live_test.dart | 69 ++++++- 5 files changed, 348 insertions(+), 12 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index a5f786321f54..2f7bedb90569 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -104,6 +104,8 @@ export 'src/imagen/imagen_reference.dart' ImagenControlReference; export 'src/live_api.dart' show + ActivityDetectionConfig, + ActivityHandling, AudioTranscriptionConfig, ContextWindowCompressionConfig, GoingAwayNotice, @@ -113,10 +115,13 @@ export 'src/live_api.dart' LiveServerToolCall, LiveServerToolCallCancellation, LiveServerResponse, + RealtimeInputConfig, + Sensitivity, SessionResumptionConfig, SessionResumptionUpdate, SlidingWindow, - Transcription; + Transcription, + TurnCoverage; export 'src/live_session.dart' show LiveSession; export 'src/mime_types.dart' show FirebaseAIMimeTypes; export 'src/schema.dart' show JSONSchema, Schema, SchemaType; diff --git a/packages/firebase_ai/firebase_ai/lib/src/live_api.dart b/packages/firebase_ai/firebase_ai/lib/src/live_api.dart index b961d5348786..c2ab340aa022 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/live_api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/live_api.dart @@ -98,6 +98,141 @@ class SessionResumptionConfig { }; } +/// Configures model input behavior when generating content in the Live API via the realtime supported methods. +final class RealtimeInputConfig { + /// Creates a [RealtimeInputConfig] instance. + RealtimeInputConfig({ + this.automaticActivityDetection, + this.activityHandling, + this.turnCoverage, + }); + + /// Configures automatic activity detection on the model. + final ActivityDetectionConfig? automaticActivityDetection; + + /// Defines how the model treats user input activity. + final ActivityHandling? activityHandling; + + /// Defines which input is included in the user's turn, relative to the starting and ending of the activity. + final TurnCoverage? turnCoverage; + + // ignore: public_member_api_docs + Map toJson() => { + if (automaticActivityDetection case final automaticActivityDetection?) + 'automatic_activity_detection': automaticActivityDetection.toJson(), + if (activityHandling case final activityHandling?) + 'activity_handling': activityHandling.value, + if (turnCoverage case final turnCoverage?) + 'turn_coverage': turnCoverage.value, + }; +} + +/// Configures the model's automatic detection of user activity. +final class ActivityDetectionConfig { + /// Creates an [ActivityDetectionConfig] instance. + ActivityDetectionConfig({ + Sensitivity? startSensitivity, + Sensitivity? endSensitivity, + int? prefixPaddingMS, + int? silenceDurationMS, + }) : this._( + startSensitivity: startSensitivity, + endSensitivity: endSensitivity, + prefixPaddingMS: prefixPaddingMS, + silenceDurationMS: silenceDurationMS, + ); + + ActivityDetectionConfig._({ + this.startSensitivity, + this.endSensitivity, + this.prefixPaddingMS, + this.silenceDurationMS, + this.disabled, + }); + + /// Disables automatic activity detection. + factory ActivityDetectionConfig.disabled() { + return ActivityDetectionConfig._( + disabled: true, + ); + } + + /// Determines how likely the start of speech is detected. + final Sensitivity? startSensitivity; + + /// Determines how likely the end of speech is detected. + final Sensitivity? endSensitivity; + + /// How long detected speech should be present before start-of-speech is committed. + final int? prefixPaddingMS; + + /// How long silence (or non-speech) should be present before end-of-speech is committed. + final int? silenceDurationMS; + + /// Whether automatic activity detection is disabled. + final bool? disabled; + + // ignore: public_member_api_docs + Map toJson() => { + if (startSensitivity case final startSensitivity?) + 'start_of_speech_sensitivity': + 'START_${startSensitivity.value}', + if (endSensitivity case final endSensitivity?) + 'end_of_speech_sensitivity': + 'END_${endSensitivity.value}', + if (prefixPaddingMS case final prefixPaddingMS?) + 'prefix_padding_ms': prefixPaddingMS, + if (silenceDurationMS case final silenceDurationMS?) + 'silence_duration_ms': silenceDurationMS, + if (disabled case final disabled?) 'disabled': disabled, + }; +} + +/// How a model handles user input activity. +enum ActivityHandling { + /// When the user sends input marking the start of activity, the model's current response will be cut-off immediately. + interrupt('START_OF_ACTIVITY_INTERRUPTS'), + + /// When the user sends input marking the start of activity, the model will process it, but won't cut-off its current response. + noInterrupt('NO_INTERRUPTION'); + + const ActivityHandling(this.value); + + /// The JSON wire string value. + final String value; +} + +/// How the model considers which input is included in the user's turn. +enum TurnCoverage { + /// The model will exclude inactivity (e.g, silence on the audio stream) from the user's input. + onlyActivity('TURN_INCLUDES_ONLY_ACTIVITY'), + + /// The model will include all input (including inactivity) since the last turn as the user's input. + allInput('TURN_INCLUDES_ALL_INPUT'), + + /// Includes audio activity and all video since the last turn. + audioActivityAndAllVideo('TURN_INCLUDES_AUDIO_ACTIVITY_AND_ALL_VIDEO'); + + const TurnCoverage(this.value); + + /// The JSON wire string value. + final String value; +} + +/// How sensitive the model interprets speech activity. +enum Sensitivity { + /// The model will detect speech less often. + low('SENSITIVITY_LOW'), + + /// The model will detect speech more often. + high('SENSITIVITY_HIGH'); + + const Sensitivity(this.value); + + /// The JSON wire string value. + final String value; +} + /// Configures live generation settings. final class LiveGenerationConfig extends BaseGenerationConfig { // ignore: public_member_api_docs @@ -106,6 +241,7 @@ final class LiveGenerationConfig extends BaseGenerationConfig { this.inputAudioTranscription, this.outputAudioTranscription, this.contextWindowCompression, + this.realtimeInputConfig, super.responseModalities, super.maxOutputTokens, super.temperature, @@ -125,6 +261,9 @@ final class LiveGenerationConfig extends BaseGenerationConfig { /// The context window compression configuration. final ContextWindowCompressionConfig? contextWindowCompression; + /// The realtime input configuration for voice activity detection and handling. + final RealtimeInputConfig? realtimeInputConfig; + @override Map toJson() => { ...super.toJson(), @@ -291,6 +430,8 @@ class LiveClientRealtimeInput { this.audio, this.video, this.text, + this.activityStart, + this.activityEnd, }); /// Creates a [LiveClientRealtimeInput] with audio data. @@ -298,21 +439,47 @@ class LiveClientRealtimeInput { // ignore: deprecated_member_use_from_same_package : mediaChunks = null, video = null, - text = null; + text = null, + activityStart = null, + activityEnd = null; /// Creates a [LiveClientRealtimeInput] with video data. LiveClientRealtimeInput.video(this.video) // ignore: deprecated_member_use_from_same_package : mediaChunks = null, audio = null, - text = null; + text = null, + activityStart = null, + activityEnd = null; /// Creates a [LiveClientRealtimeInput] with text data. LiveClientRealtimeInput.text(this.text) // ignore: deprecated_member_use_from_same_package : mediaChunks = null, audio = null, - video = null; + video = null, + activityStart = null, + activityEnd = null; + + /// Creates a [LiveClientRealtimeInput] with activity start signal. + LiveClientRealtimeInput.activityStart() + // ignore: deprecated_member_use_from_same_package + : mediaChunks = null, + audio = null, + video = null, + text = null, + activityStart = const {}, + activityEnd = null; + + /// Creates a [LiveClientRealtimeInput] with activity end signal. + LiveClientRealtimeInput.activityEnd() + // ignore: deprecated_member_use_from_same_package + : mediaChunks = null, + audio = null, + video = null, + text = null, + activityStart = null, + activityEnd = const {}; /// The list of media chunks. @Deprecated('Use audio, video, or text instead') @@ -327,15 +494,24 @@ class LiveClientRealtimeInput { /// Text data. final String? text; + /// Activity start signal. + final Map? activityStart; + + /// Activity end signal. + final Map? activityEnd; + // ignore: public_member_api_docs Map toJson() => { 'realtime_input': { - 'media_chunks': - // ignore: deprecated_member_use_from_same_package - mediaChunks?.map((e) => e.toMediaChunkJson()).toList(), + if (mediaChunks != null) + 'media_chunks': + // ignore: deprecated_member_use_from_same_package + mediaChunks?.map((e) => e.toMediaChunkJson()).toList(), if (audio != null) 'audio': audio!.toMediaChunkJson(), if (video != null) 'video': video!.toMediaChunkJson(), if (text != null) 'text': text, + if (activityStart != null) 'activity_start': activityStart, + if (activityEnd != null) 'activity_end': activityEnd, }, }; } diff --git a/packages/firebase_ai/firebase_ai/lib/src/live_session.dart b/packages/firebase_ai/firebase_ai/lib/src/live_session.dart index d69fd7f678c2..d82a566de6c4 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/live_session.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/live_session.dart @@ -134,7 +134,10 @@ class LiveSession { liveGenerationConfig.outputAudioTranscription!.toJson(), if (liveGenerationConfig.contextWindowCompression case final contextWindowCompression?) - 'contextWindowCompression': contextWindowCompression.toJson() + 'contextWindowCompression': contextWindowCompression.toJson(), + if (liveGenerationConfig.realtimeInputConfig + case final realtimeInputConfig?) + 'realtime_input_config': realtimeInputConfig.toJson(), }, } }; @@ -287,6 +290,39 @@ class LiveSession { _ws.sink.add(clientJson); } + /// Manually marks the start of user activity, using the realtime API. + /// + /// The start of user activity is effectively the start of a user's turn, but + /// depending on the configuration defined in [RealtimeInputConfig], it may not + /// be interpreted as an interruption. An example of the start of user activity + /// could be the user speaking (not silence). + /// + /// Should be followed with a call to [sendStopActivityRealtime]; after all the + /// data has been sent for the user's turn. + /// + /// Only required when automatic activity detection is disabled via [RealtimeInputConfig]. + Future sendStartActivityRealtime() async { + _checkWsStatus(); + var clientMessage = LiveClientRealtimeInput.activityStart(); + var clientJson = jsonEncode(clientMessage.toJson()); + _ws.sink.add(clientJson); + } + + /// Manually marks the end of user activity, using the realtime API. + /// + /// The end of user activity is effectively the end of a user's turn, and + /// signals that the model can start sending responses. + /// + /// Should follow after a previous call to [sendStartActivityRealtime]. + /// + /// Only required when automatic activity detection is disabled via [RealtimeInputConfig]. + Future sendStopActivityRealtime() async { + _checkWsStatus(); + var clientMessage = LiveClientRealtimeInput.activityEnd(); + var clientJson = jsonEncode(clientMessage.toJson()); + _ws.sink.add(clientJson); + } + /// Sends realtime input (media chunks) to the server. /// /// [mediaChunks]: The list of media chunks to send. diff --git a/packages/firebase_ai/firebase_ai/test/live_session_test.dart b/packages/firebase_ai/firebase_ai/test/live_session_test.dart index 693aba00b26a..49269af7cd35 100644 --- a/packages/firebase_ai/firebase_ai/test/live_session_test.dart +++ b/packages/firebase_ai/firebase_ai/test/live_session_test.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ignore_for_file: close_sinks + import 'dart:async'; import 'dart:convert'; @@ -20,14 +22,38 @@ import 'package:firebase_ai/src/live_session.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +class FakeWebSocketSink implements WebSocketSink { + final List sentMessages = []; + + @override + void add(dynamic data) { + sentMessages.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) async {} + + @override + Future close([int? closeCode, String? closeReason]) async {} + + @override + Future get done => Future.value(); +} + class FakeWebSocketChannel implements WebSocketChannel { final StreamController _controller = StreamController(); + final FakeWebSocketSink _sink = FakeWebSocketSink(); @override Stream get stream => _controller.stream; @override - WebSocketSink get sink => throw UnimplementedError(); + WebSocketSink get sink => _sink; + + List get sentMessages => _sink.sentMessages; void emit(dynamic message) => _controller.add(message); @@ -95,5 +121,35 @@ void main() { await subscription.cancel(); fakeWs.close(); }); + + test('sendStartActivityRealtime sends correct activity_start message', () async { + final fakeWs = FakeWebSocketChannel(); + final session = LiveSession.forTesting(fakeWs); + + await session.sendStartActivityRealtime(); + + expect(fakeWs.sentMessages.length, 1); + final jsonPayload = json.decode(fakeWs.sentMessages.first as String); + expect(jsonPayload, { + 'realtime_input': { + 'activity_start': {}, + }, + }); + }); + + test('sendStopActivityRealtime sends correct activity_end message', () async { + final fakeWs = FakeWebSocketChannel(); + final session = LiveSession.forTesting(fakeWs); + + await session.sendStopActivityRealtime(); + + expect(fakeWs.sentMessages.length, 1); + final jsonPayload = json.decode(fakeWs.sentMessages.first as String); + expect(jsonPayload, { + 'realtime_input': { + 'activity_end': {}, + }, + }); + }); }); } diff --git a/packages/firebase_ai/firebase_ai/test/live_test.dart b/packages/firebase_ai/firebase_ai/test/live_test.dart index 9965c59ca46b..585952b8f9a5 100644 --- a/packages/firebase_ai/firebase_ai/test/live_test.dart +++ b/packages/firebase_ai/firebase_ai/test/live_test.dart @@ -218,9 +218,7 @@ void main() { final message2 = LiveClientRealtimeInput(); expect(message2.toJson(), { - 'realtime_input': { - 'media_chunks': null, - }, + 'realtime_input': {}, }); }); @@ -387,5 +385,70 @@ void main() { expect(contentMessage.outputTranscription?.text, 'output'); expect(contentMessage.outputTranscription?.finished, false); }); + + test('ActivityDetectionConfig toJson() returns correct JSON', () { + final config = ActivityDetectionConfig( + startSensitivity: Sensitivity.low, + endSensitivity: Sensitivity.high, + prefixPaddingMS: 100, + silenceDurationMS: 500, + ); + expect(config.toJson(), { + 'start_of_speech_sensitivity': 'START_SENSITIVITY_LOW', + 'end_of_speech_sensitivity': 'END_SENSITIVITY_HIGH', + 'prefix_padding_ms': 100, + 'silence_duration_ms': 500, + }); + + final disabledConfig = ActivityDetectionConfig.disabled(); + expect(disabledConfig.toJson(), { + 'disabled': true, + }); + + final emptyConfig = ActivityDetectionConfig(); + expect(emptyConfig.toJson(), {}); + }); + + test('RealtimeInputConfig toJson() returns correct JSON', () { + final config = RealtimeInputConfig( + automaticActivityDetection: ActivityDetectionConfig.disabled(), + activityHandling: ActivityHandling.interrupt, + turnCoverage: TurnCoverage.onlyActivity, + ); + expect(config.toJson(), { + 'automatic_activity_detection': {'disabled': true}, + 'activity_handling': 'START_OF_ACTIVITY_INTERRUPTS', + 'turn_coverage': 'TURN_INCLUDES_ONLY_ACTIVITY', + }); + + final emptyConfig = RealtimeInputConfig(); + expect(emptyConfig.toJson(), {}); + }); + + test('LiveGenerationConfig with realtimeInputConfig toJson() returns correct JSON', () { + final liveGenerationConfig = LiveGenerationConfig( + realtimeInputConfig: RealtimeInputConfig( + automaticActivityDetection: ActivityDetectionConfig.disabled(), + ), + ); + // Explicitly, realtimeInputConfig should not exist in generation_config toJson() directly + expect(liveGenerationConfig.toJson(), {}); + }); + + test('LiveClientRealtimeInput activityStart and activityEnd toJson()', () { + final startMessage = LiveClientRealtimeInput.activityStart(); + expect(startMessage.toJson(), { + 'realtime_input': { + 'activity_start': {}, + }, + }); + + final stopMessage = LiveClientRealtimeInput.activityEnd(); + expect(stopMessage.toJson(), { + 'realtime_input': { + 'activity_end': {}, + }, + }); + }); }); } From faf7d5f6a9a7d2b0620a7a26fb72d129d14f2d8f Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 14 Jul 2026 15:51:18 -0700 Subject: [PATCH 2/2] add the realtime input control to example --- .../example/lib/pages/bidi_page.dart | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart index 56c7e950d0ce..08ff63d0be7c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart @@ -170,12 +170,84 @@ class BidiSessionController extends ChangeNotifier { int? _inputTranscriptionMessageIndex; int? _outputTranscriptionMessageIndex; + bool disableAutomaticActivityDetection = false; + ActivityHandling? selectedActivityHandling; + TurnCoverage? selectedTurnCoverage; + Sensitivity? selectedStartSensitivity; + Sensitivity? selectedEndSensitivity; + + void updateRealtimeConfig({ + bool? disableVAD, + ActivityHandling? activityHandling, + TurnCoverage? turnCoverage, + Sensitivity? startSensitivity, + Sensitivity? endSensitivity, + bool clearActivityHandling = false, + bool clearTurnCoverage = false, + bool clearStartSensitivity = false, + bool clearEndSensitivity = false, + }) { + if (disableVAD != null) disableAutomaticActivityDetection = disableVAD; + if (clearActivityHandling) { + selectedActivityHandling = null; + } else if (activityHandling != null) { + selectedActivityHandling = activityHandling; + } + if (clearTurnCoverage) { + selectedTurnCoverage = null; + } else if (turnCoverage != null) { + selectedTurnCoverage = turnCoverage; + } + if (clearStartSensitivity) { + selectedStartSensitivity = null; + } else if (startSensitivity != null) { + selectedStartSensitivity = startSensitivity; + } + if (clearEndSensitivity) { + selectedEndSensitivity = null; + } else if (endSensitivity != null) { + selectedEndSensitivity = endSensitivity; + } + _initLiveModel(); + notifyListeners(); + } + void _initLiveModel() { + final RealtimeInputConfig? realtimeInputConfig; + final bool hasSensitivities = + selectedStartSensitivity != null || selectedEndSensitivity != null; + final ActivityDetectionConfig? automaticActivityDetection; + + if (disableAutomaticActivityDetection) { + automaticActivityDetection = ActivityDetectionConfig.disabled(); + } else if (hasSensitivities) { + automaticActivityDetection = ActivityDetectionConfig( + startSensitivity: selectedStartSensitivity, + endSensitivity: selectedEndSensitivity, + ); + } else { + automaticActivityDetection = null; + } + + if (disableAutomaticActivityDetection || + hasSensitivities || + selectedActivityHandling != null || + selectedTurnCoverage != null) { + realtimeInputConfig = RealtimeInputConfig( + automaticActivityDetection: automaticActivityDetection, + activityHandling: selectedActivityHandling, + turnCoverage: selectedTurnCoverage, + ); + } else { + realtimeInputConfig = null; + } + final config = LiveGenerationConfig( speechConfig: SpeechConfig(voiceName: 'Fenrir'), responseModalities: [ResponseModalities.audio], inputAudioTranscription: AudioTranscriptionConfig(), outputAudioTranscription: AudioTranscriptionConfig(), + realtimeInputConfig: realtimeInputConfig, ); final tools = [ @@ -196,6 +268,40 @@ class BidiSessionController extends ChangeNotifier { ); } + Future sendStartActivity() async { + if (!isSessionActive || _session == null) return; + try { + await _session!.sendStartActivityRealtime(); + messages.add( + MessageData( + text: '[System] Sent manual start activity signal', + fromUser: true, + ), + ); + onScrollDown?.call(); + notifyListeners(); + } catch (e) { + onShowError?.call(e.toString()); + } + } + + Future sendStopActivity() async { + if (!isSessionActive || _session == null) return; + try { + await _session!.sendStopActivityRealtime(); + messages.add( + MessageData( + text: '[System] Sent manual stop activity signal', + fromUser: true, + ), + ); + onScrollDown?.call(); + notifyListeners(); + } catch (e) { + onShowError?.call(e.toString()); + } + } + Future initialize() async { isLoading = true; notifyListeners(); @@ -657,6 +763,176 @@ class _BidiPageState extends State { ], ), const SizedBox(height: 8), + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ExpansionTile( + title: const Text( + 'Realtime Input Config Settings', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + subtitle: Text( + _controller.disableAutomaticActivityDetection + ? 'Auto VAD: Disabled (Manual signals required)' + : 'Auto VAD: Enabled', + style: TextStyle( + fontSize: 12, + color: _controller.disableAutomaticActivityDetection + ? Colors.orangeAccent + : Colors.greenAccent, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Disable Automatic VAD:'), + Switch( + value: _controller + .disableAutomaticActivityDetection, + onChanged: (val) { + _controller.updateRealtimeConfig( + disableVAD: val, + ); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow, size: 16), + label: const Text('Start Activity'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade800, + ), + onPressed: _controller.isSessionActive + ? () => _controller.sendStartActivity() + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.stop, size: 16), + label: const Text('Stop Activity'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade800, + ), + onPressed: _controller.isSessionActive + ? () => _controller.sendStopActivity() + : null, + ), + ), + ], + ), + const Divider(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DropdownButton( + value: _controller.selectedActivityHandling, + hint: const Text('Activity Handling'), + items: [ + const DropdownMenuItem( + child: Text('Handling: Default'), + ), + ...ActivityHandling.values.map( + (e) => DropdownMenuItem( + value: e, + child: Text('Handling: ${e.name}'), + ), + ), + ], + onChanged: (val) { + _controller.updateRealtimeConfig( + activityHandling: val, + clearActivityHandling: val == null, + ); + }, + ), + DropdownButton( + value: _controller.selectedTurnCoverage, + hint: const Text('Turn Coverage'), + items: [ + const DropdownMenuItem( + child: Text('Coverage: Default'), + ), + ...TurnCoverage.values.map( + (e) => DropdownMenuItem( + value: e, + child: Text('Coverage: ${e.name}'), + ), + ), + ], + onChanged: (val) { + _controller.updateRealtimeConfig( + turnCoverage: val, + clearTurnCoverage: val == null, + ); + }, + ), + DropdownButton( + value: _controller.selectedStartSensitivity, + hint: const Text('Start Sensitivity'), + items: [ + const DropdownMenuItem( + child: Text('Start Sens: Default'), + ), + ...Sensitivity.values.map( + (e) => DropdownMenuItem( + value: e, + child: Text('Start Sens: ${e.name}'), + ), + ), + ], + onChanged: (val) { + _controller.updateRealtimeConfig( + startSensitivity: val, + clearStartSensitivity: val == null, + ); + }, + ), + DropdownButton( + value: _controller.selectedEndSensitivity, + hint: const Text('End Sensitivity'), + items: [ + const DropdownMenuItem( + child: Text('End Sens: Default'), + ), + ...Sensitivity.values.map( + (e) => DropdownMenuItem( + value: e, + child: Text('End Sens: ${e.name}'), + ), + ), + ], + onChanged: (val) { + _controller.updateRealtimeConfig( + endSensitivity: val, + clearEndSensitivity: val == null, + ); + }, + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), if (_controller.isCameraOn) Container( height: 200,