Skip to content

Commit 787a0a3

Browse files
committed
feat(audio): engine-wide audio processing state read-back
Surfaces the WebRTC-SDK v2 audio processing state on AudioManager: the audio processing module is owned by the native peer connection factory and shared engine-wide, so the snapshot reflects what is actually applied across the engine rather than any single track. Dart models follow the v2 contract per component: requested (caller intent, null when nothing was ever applied) -> software/platform resolved -> active, with effective as the merged verdict. The iOS and Android plugins read factory.audioProcessingState and serialize it over the method channel; Android reaches the factory through the flutter_webrtc getPeerConnectionFactory accessor.
1 parent 0dc8b89 commit 787a0a3

8 files changed

Lines changed: 329 additions & 8 deletions

File tree

android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ import com.cloudwebrtc.webrtc.audio.LocalAudioTrack
3030
import io.flutter.plugin.common.BinaryMessenger
3131
import org.webrtc.AudioTrack
3232
import org.webrtc.audio.AudioProcessingComponentOptions
33+
import org.webrtc.audio.AudioProcessingComponentState
34+
import org.webrtc.audio.AudioProcessingImplementation
3335
import org.webrtc.audio.AudioProcessingMode
3436
import org.webrtc.audio.AudioProcessingOptions
3537
import org.webrtc.audio.AudioProcessingOptionsResult
38+
import org.webrtc.audio.AudioProcessingState
3639

3740
/** LiveKitPlugin */
3841
class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
@@ -271,6 +274,56 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
271274
AudioProcessingOptionsResult.Code.APPLY_FAILED -> "applyFailed"
272275
}
273276

277+
private fun handleGetAudioProcessingState(result: Result) {
278+
val factory = flutterWebRTCPlugin.getPeerConnectionFactory()
279+
if (factory == null) {
280+
result.success(null)
281+
return
282+
}
283+
result.success(audioProcessingStateToMap(factory.audioProcessingState))
284+
}
285+
286+
private fun audioProcessingModeString(mode: AudioProcessingMode): String = when (mode) {
287+
AudioProcessingMode.PLATFORM -> "platform"
288+
AudioProcessingMode.SOFTWARE -> "software"
289+
AudioProcessingMode.AUTOMATIC -> "auto"
290+
}
291+
292+
private fun audioProcessingImplementationString(implementation: AudioProcessingImplementation): String =
293+
when (implementation) {
294+
AudioProcessingImplementation.UNKNOWN -> "unknown"
295+
AudioProcessingImplementation.DISABLED -> "disabled"
296+
AudioProcessingImplementation.SOFTWARE -> "software"
297+
AudioProcessingImplementation.PLATFORM -> "platform"
298+
AudioProcessingImplementation.SOFTWARE_AND_PLATFORM -> "softwareAndPlatform"
299+
}
300+
301+
private fun requestedToMap(requested: AudioProcessingComponentOptions?): Map<String, Any?>? =
302+
requested?.let {
303+
mapOf(
304+
"enabled" to it.isEnabled,
305+
"mode" to audioProcessingModeString(it.mode),
306+
)
307+
}
308+
309+
private fun componentToMap(state: AudioProcessingComponentState): Map<String, Any?> = mapOf(
310+
"requested" to requestedToMap(state.requested),
311+
"isSoftwareResolved" to state.isSoftwareResolved,
312+
"isSoftwareActive" to state.isSoftwareActive,
313+
"isPlatformAvailable" to state.isPlatformAvailable,
314+
"isPlatformResolved" to state.isPlatformResolved,
315+
"isPlatformActive" to state.isPlatformActive,
316+
"effective" to audioProcessingImplementationString(state.effective),
317+
)
318+
319+
private fun audioProcessingStateToMap(state: AudioProcessingState): Map<String, Any?> = mapOf(
320+
"hasAudioProcessingModule" to state.hasAudioProcessingModule,
321+
"echoCancellation" to componentToMap(state.echoCancellation),
322+
"noiseSuppression" to componentToMap(state.noiseSuppression),
323+
"autoGainControl" to componentToMap(state.autoGainControl),
324+
"highPassFilter" to componentToMap(state.highPassFilter),
325+
)
326+
274327
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
275328
when (call.method) {
276329
"startVisualizer" -> {
@@ -293,6 +346,10 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
293346
handleSetAudioProcessingOptions(call, result)
294347
}
295348

349+
"getAudioProcessingState" -> {
350+
handleGetAudioProcessingState(result)
351+
}
352+
296353
else -> {
297354
result.notImplemented()
298355
}

lib/livekit_client.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ export 'src/agent/room_agent.dart';
4141
export 'src/participant/local.dart';
4242
export 'src/participant/participant.dart';
4343
export 'src/participant/remote.dart' hide ParticipantCreationResult;
44+
export 'src/audio/audio_manager.dart';
4445
export 'src/audio/audio_frame_capture.dart' show AudioFormat, AudioFrame, AudioFrameCallback, AudioRendererOptions;
4546
export 'src/preconnect/pre_connect_audio_buffer.dart';
4647
export 'src/publication/local.dart';
4748
export 'src/publication/remote.dart';
4849
export 'src/publication/track_publication.dart';
4950
export 'src/support/platform.dart';
51+
export 'src/audio/audio_processing_state.dart';
5052
export 'src/track/audio_visualizer.dart';
5153
export 'src/track/local/audio.dart';
5254
export 'src/track/local/local.dart';

lib/src/audio/audio_manager.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2024 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import '../support/native.dart';
16+
import 'audio_processing_state.dart';
17+
18+
/// Controls LiveKit's process-wide platform audio behavior.
19+
///
20+
/// The platform audio engine and its audio processing module are global to the
21+
/// app process, so engine-scoped audio state lives here rather than on a `Room`
22+
/// or an individual track.
23+
class AudioManager {
24+
AudioManager._();
25+
26+
static final AudioManager instance = AudioManager._();
27+
28+
/// Diagnostic snapshot of the resolved audio processing state.
29+
///
30+
/// The audio processing module is owned by the native peer connection factory
31+
/// and shared engine-wide, so this reflects what is actually applied across
32+
/// the engine rather than any single track — use it to verify what a
33+
/// `LocalAudioTrack.setAudioProcessingOptions` request resolved to. Returns
34+
/// `null` when the native side cannot provide it.
35+
Future<AudioProcessingState?> getAudioProcessingState() async {
36+
final response = await Native.getAudioProcessingState();
37+
if (response == null) return null;
38+
return AudioProcessingState.fromMap(response);
39+
}
40+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import '../track/options.dart';
16+
17+
/// The implementation in effect for an audio processing component.
18+
enum AudioProcessingImplementation {
19+
unknown('unknown'),
20+
disabled('disabled'),
21+
software('software'),
22+
platform('platform'),
23+
softwareAndPlatform('softwareAndPlatform');
24+
25+
const AudioProcessingImplementation(this.value);
26+
27+
final String value;
28+
29+
static AudioProcessingImplementation fromValue(String? value) => AudioProcessingImplementation.values.firstWhere(
30+
(e) => e.value == value,
31+
orElse: () => AudioProcessingImplementation.unknown,
32+
);
33+
}
34+
35+
AudioProcessingMode _modeFromValue(String? value) {
36+
for (final mode in AudioProcessingMode.values) {
37+
if (mode.constraintValue == value) return mode;
38+
}
39+
return AudioProcessingMode.automatic;
40+
}
41+
42+
/// The caller's request for one audio processing component: enabled flag plus
43+
/// implementation mode.
44+
class AudioProcessingComponentRequest {
45+
const AudioProcessingComponentRequest({
46+
required this.enabled,
47+
required this.mode,
48+
});
49+
50+
factory AudioProcessingComponentRequest.fromMap(Map<dynamic, dynamic> map) => AudioProcessingComponentRequest(
51+
enabled: (map['enabled'] as bool?) ?? false,
52+
mode: _modeFromValue(map['mode'] as String?),
53+
);
54+
55+
final bool enabled;
56+
final AudioProcessingMode mode;
57+
}
58+
59+
/// Diagnostic state of one audio processing component (echo cancellation,
60+
/// noise suppression, auto gain control or high-pass filter), observed at
61+
/// three stages of one pipeline: requested (caller intent) -> resolved (the
62+
/// engine's per-path decision) -> active (live truth), with [effective] as
63+
/// the merged verdict.
64+
class AudioProcessingComponentState {
65+
const AudioProcessingComponentState({
66+
this.requested,
67+
required this.isSoftwareResolved,
68+
required this.isSoftwareActive,
69+
required this.isPlatformAvailable,
70+
required this.isPlatformResolved,
71+
required this.isPlatformActive,
72+
required this.effective,
73+
});
74+
75+
factory AudioProcessingComponentState.fromMap(Map<dynamic, dynamic> map) => AudioProcessingComponentState(
76+
requested: map['requested'] is Map
77+
? AudioProcessingComponentRequest.fromMap(Map<dynamic, dynamic>.from(map['requested'] as Map))
78+
: null,
79+
isSoftwareResolved: (map['isSoftwareResolved'] as bool?) ?? false,
80+
isSoftwareActive: (map['isSoftwareActive'] as bool?) ?? false,
81+
isPlatformAvailable: (map['isPlatformAvailable'] as bool?) ?? false,
82+
isPlatformResolved: (map['isPlatformResolved'] as bool?) ?? false,
83+
isPlatformActive: (map['isPlatformActive'] as bool?) ?? false,
84+
effective: AudioProcessingImplementation.fromValue(map['effective'] as String?),
85+
);
86+
87+
/// What the caller most recently requested for this component. Null when no
88+
/// audio processing options have ever been applied — "nobody asked".
89+
final AudioProcessingComponentRequest? requested;
90+
91+
/// Whether the resolver decided the WebRTC software (APM) implementation
92+
/// should run, after weighing the requested mode against platform
93+
/// availability, coupling, and policy.
94+
final bool isSoftwareResolved;
95+
96+
/// Whether APM's live configuration currently has this component enabled.
97+
final bool isSoftwareActive;
98+
99+
/// Whether this device/OS offers a built-in implementation at all.
100+
final bool isPlatformAvailable;
101+
102+
/// Whether the engine asked the OS to run the platform implementation. The
103+
/// OS owns the outcome: it can decline, defer, or couple components.
104+
final bool isPlatformResolved;
105+
106+
/// Whether the device reports the platform implementation actually running.
107+
final bool isPlatformActive;
108+
109+
/// The verdict: which implementation is in effect right now.
110+
final AudioProcessingImplementation effective;
111+
}
112+
113+
/// Diagnostic snapshot of the resolved audio processing state for the shared
114+
/// audio processing module.
115+
///
116+
/// The module is owned by the native peer connection factory and shared
117+
/// engine-wide, so this reflects what is actually applied (per-component
118+
/// [AudioProcessingComponentState.effective]) versus what was requested — for
119+
/// the whole engine, not a single track.
120+
class AudioProcessingState {
121+
const AudioProcessingState({
122+
required this.hasAudioProcessingModule,
123+
required this.echoCancellation,
124+
required this.noiseSuppression,
125+
required this.autoGainControl,
126+
required this.highPassFilter,
127+
});
128+
129+
factory AudioProcessingState.fromMap(Map<dynamic, dynamic> map) => AudioProcessingState(
130+
hasAudioProcessingModule: (map['hasAudioProcessingModule'] as bool?) ?? false,
131+
echoCancellation:
132+
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['echoCancellation'] as Map)),
133+
noiseSuppression:
134+
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['noiseSuppression'] as Map)),
135+
autoGainControl:
136+
AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['autoGainControl'] as Map)),
137+
highPassFilter: AudioProcessingComponentState.fromMap(Map<dynamic, dynamic>.from(map['highPassFilter'] as Map)),
138+
);
139+
140+
final bool hasAudioProcessingModule;
141+
final AudioProcessingComponentState echoCancellation;
142+
final AudioProcessingComponentState noiseSuppression;
143+
final AudioProcessingComponentState autoGainControl;
144+
final AudioProcessingComponentState highPassFilter;
145+
}

lib/src/support/native.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ class Native {
7474
return <String, dynamic>{};
7575
}
7676

77+
/// Reads the engine-wide audio processing state from the native peer
78+
/// connection factory. Returns `null` when unavailable (e.g. the factory
79+
/// does not exist yet, or the platform cannot provide it).
80+
@internal
81+
static Future<Map<String, dynamic>?> getAudioProcessingState() async {
82+
try {
83+
final response = await channel.invokeMethod<dynamic>(
84+
'getAudioProcessingState',
85+
<String, dynamic>{},
86+
);
87+
if (response is Map) {
88+
return response.map((key, value) => MapEntry(key.toString(), value));
89+
}
90+
} catch (error) {
91+
logger.warning('getAudioProcessingState did throw $error');
92+
}
93+
return null;
94+
}
95+
7796
@internal
7897
static Future<bool> startVisualizer(
7998
String trackId, {

lib/src/track/local/audio.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi
176176
await track.setProcessor(options.processor);
177177
}
178178

179+
// Per-component processing modes are not part of standard capture
180+
// constraints; apply them through the native audio processing path.
181+
final processing = options.processing;
182+
if (processing.echoCancellationMode != track_options.AudioProcessingMode.automatic ||
183+
processing.noiseSuppressionMode != track_options.AudioProcessingMode.automatic ||
184+
processing.autoGainControlMode != track_options.AudioProcessingMode.automatic ||
185+
processing.highPassFilterMode != track_options.AudioProcessingMode.automatic) {
186+
await track.setAudioProcessingOptions(processing);
187+
}
188+
179189
return track;
180190
}
181191
}

lib/src/track/options.dart

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -421,10 +421,6 @@ class AudioCaptureOptions extends LocalTrackOptions implements AudioProcessingOp
421421
<String, dynamic>{'autoGainControl': false},
422422
<String, dynamic>{'voiceIsolation': false},
423423
<String, dynamic>{'googDAEchoCancellation': false},
424-
if (!kIsWeb) <String, dynamic>{'echoCancellationMode': echoCancellationMode.constraintValue},
425-
if (!kIsWeb) <String, dynamic>{'noiseSuppressionMode': noiseSuppressionMode.constraintValue},
426-
if (!kIsWeb) <String, dynamic>{'autoGainControlMode': autoGainControlMode.constraintValue},
427-
if (!kIsWeb) <String, dynamic>{'highPassFilterMode': highPassFilterMode.constraintValue},
428424
];
429425
} else {
430426
/// in we platform it's not possible to provide optional and mandatory parameters.
@@ -443,10 +439,6 @@ class AudioCaptureOptions extends LocalTrackOptions implements AudioProcessingOp
443439
<String, dynamic>{'googAutoGainControl': autoGainControl},
444440
<String, dynamic>{'googHighpassFilter': highPassFilter},
445441
<String, dynamic>{'googTypingNoiseDetection': typingNoiseDetection},
446-
if (!kIsWeb) <String, dynamic>{'echoCancellationMode': echoCancellationMode.constraintValue},
447-
if (!kIsWeb) <String, dynamic>{'noiseSuppressionMode': noiseSuppressionMode.constraintValue},
448-
if (!kIsWeb) <String, dynamic>{'autoGainControlMode': autoGainControlMode.constraintValue},
449-
if (!kIsWeb) <String, dynamic>{'highPassFilterMode': highPassFilterMode.constraintValue},
450442
];
451443
}
452444
}

0 commit comments

Comments
 (0)