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
5 changes: 5 additions & 0 deletions .changeset/cold-plants-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": minor
---

Add `LocalAudioTrack.applyOptions`, which allows updating the audio track options on the fly.
5 changes: 5 additions & 0 deletions .changeset/cuddly-mugs-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": patch
---

Fix custom LocalAudioTrackOptions not applying correctly
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
webrtc = "144.7559.05"
webrtc = "144.7559.09"

androidJainSipRi = "1.3.0-91"
androidx-activity = "1.9.0"
Expand Down
3 changes: 1 addition & 2 deletions livekit-android-sdk/detekt-baseline-release.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
<ID>CyclomaticComplexMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse)</ID>
<ID>EmptyFunctionBlock:RTCEngine.kt$RTCEngine${ }</ID>
<ID>HasPlatformType:DataChannelManager.kt$DataChannelManager$@get:FlowObservable var state by flowDelegate(dataChannel.state()) private set</ID>
<ID>IgnoredReturnValue:BaseStreamReceiver.kt$BaseStreamReceiver$catch { }</ID>
<ID>IgnoredReturnValue:RpcServerManager.kt$RpcServerManager$publishRpcAck(callerIdentity, requestId)</ID>
<ID>InstanceOfCheckForException:RpcServerManager.kt$RpcServerManager$e is RpcError</ID>
<ID>LargeClass:LocalParticipant.kt$LocalParticipant : ParticipantOutgoingDataStreamManagerRpcManager</ID>
Expand All @@ -42,7 +41,7 @@
<ID>LongParameterList:AudioBufferCallbackDispatcher.kt$AudioBufferCallback$(buffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long)</ID>
<ID>LongParameterList:KeyProvider.kt$BaseKeyProvider$( ratchetSalt: String = defaultRatchetSalt, uncryptedMagicBytes: String = defaultMagicBytes, ratchetWindowSize: Int = defaultRatchetWindowSize, override var enableSharedKey: Boolean = true, failureTolerance: Int = defaultFailureTolerance, keyRingSize: Int = defaultKeyRingSize, discardFrameWhenCryptorNotReady: Boolean = defaultDiscardFrameWhenCryptorNotReady, keyDerivationAlgorithm: FrameCryptorKeyDerivationAlgorithm = defaultKeyDerivationAlgorithm, )</ID>
<ID>LongParameterList:LiveKitOverrides.kt$AudioOptions$( /** * Override the default output [AudioType]. * * This affects the audio routing and how the audio is handled. Default is [AudioType.CallAudioType]. * * Note: if [audioHandler] is also passed, the values from [audioOutputType] will not be reflected in it, * and must be set yourself. */ val audioOutputType: AudioType? = null, /** * Override the default [AudioHandler]. * * Default is [AudioSwitchHandler]. * * Use [NoAudioHandler] to turn off automatic audio handling or * [AudioFocusHandler] to get simple audio focus handling. */ val audioHandler: AudioHandler? = null, /** * Override the default [AudioDeviceModule]. * * If a non-null value is passed, the library does not * take ownership of the object and will not release it upon [Room.release]. * It is the responsibility of the owner to call [AudioDeviceModule.release] when finished * with it to prevent memory leaks. */ val audioDeviceModule: AudioDeviceModule? = null, /** * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. * * Not used if [audioDeviceModule] is provided. * * Note: We require setting the [JavaAudioDeviceModule.Builder.setSamplesReadyCallback] to provide * support for [LocalAudioTrack.addSink]. If you wish to grab the audio samples * from the local microphone track, use [LocalAudioTrack.addSink] instead of setting your own * callback. */ val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, /** * On Android 11+, the audio mode will reset itself from [AudioManager.MODE_IN_COMMUNICATION] if * there is no audio playback or capture for 6 seconds (for example when joining a room with * no speakers and the local mic is muted.) This mode reset will cause unexpected * behavior when trying to change the volume, causing it to not properly change the volume. * * We use a workaround by playing a silent audio track to keep the communication mode from * resetting. * * Setting this flag to true will disable the workaround. * * This flag is a no-op when the audio mode is set to anything other than * [AudioManager.MODE_IN_COMMUNICATION]. */ val disableCommunicationModeWorkaround: Boolean = false, /** * Options for processing the mic and incoming audio. */ val audioProcessorOptions: AudioProcessorOptions? = null, /** * Devices may take some time initializing the audio stack for recording. * Prewarming allows starting up the underlying audio recording prior to publish, letting * the audio device be ready immediately when the track is fully published. * * If set to true, disables audio recording prewarming (and the related * [LocalAudioTrack.prewarm] function), and audio resources are only used while the * track is connected and published. Defaults to false. */ val disableAudioPrewarming: Boolean = false, )</ID>
<ID>LongParameterList:LocalAudioTrack.kt$LocalAudioTrack$( @Assisted name: String, @Assisted mediaTrack: livekit.org.webrtc.AudioTrack, @Assisted private val options: LocalAudioTrackOptions, private val audioProcessingController: AudioProcessingController, @Named(InjectionNames.DISPATCHER_DEFAULT) private val dispatcher: CoroutineDispatcher, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) private val audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) private val audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, private val audioRecordPrewarmer: AudioRecordPrewarmer, rtcThreadToken: RTCThreadToken, )</ID>
<ID>LongParameterList:LocalAudioTrack.kt$LocalAudioTrack$( @Assisted name: String, @Assisted mediaTrack: livekit.org.webrtc.AudioTrack, @Assisted options: LocalAudioTrackOptions, private val audioProcessingController: AudioProcessingController, @Named(InjectionNames.DISPATCHER_DEFAULT) private val dispatcher: CoroutineDispatcher, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) private val audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) private val audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, private val audioRecordPrewarmer: AudioRecordPrewarmer, rtcThreadToken: RTCThreadToken, )</ID>
<ID>LongParameterList:LocalParticipant.kt$LocalParticipant$( @Assisted internal var dynacast: Boolean, internal val engine: RTCEngine, private val peerConnectionFactory: PeerConnectionFactory, private val context: Context, private val eglBase: EglBase, private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, private val videoTrackFactory: LocalVideoTrack.Factory, private val audioTrackFactory: LocalAudioTrack.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) coroutineDispatcher: CoroutineDispatcher, @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, )</ID>
<ID>LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack$( @Assisted capturer: VideoCapturer, @Assisted source: VideoSource, @Assisted name: String, @Assisted options: LocalVideoTrackOptions, @Assisted rtcTrack: livekit.org.webrtc.VideoTrack, @Assisted mediaProjectionCallback: MediaProjectionCallback, peerConnectionFactory: PeerConnectionFactory, context: Context, eglBase: EglBase, defaultsManager: DefaultsManager, videoTrackFactory: LocalVideoTrack.Factory, rtcThreadToken: RTCThreadToken, )</ID>
<ID>LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack.Companion$( mediaProjectionPermissionResultData: Intent, peerConnectionFactory: PeerConnectionFactory, context: Context, name: String, options: LocalVideoTrackOptions, rootEglBase: EglBase, screencastVideoTrackFactory: Factory, videoProcessor: VideoProcessor?, onStop: (Track) -> Unit, )</ID>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2025 LiveKit, Inc.
* Copyright 2025-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,21 +16,23 @@

package io.livekit.android.audio

import io.livekit.android.room.track.LocalAudioTrackOptions
import livekit.org.webrtc.audio.AudioProcessingOptions
import livekit.org.webrtc.audio.JavaAudioDeviceModule

/**
* @suppress
*/
interface AudioRecordPrewarmer {
fun prewarm()
fun prewarm(options: LocalAudioTrackOptions)
fun stop()
}

/**
* @suppress
*/
class NoAudioRecordPrewarmer : AudioRecordPrewarmer {
override fun prewarm() {
override fun prewarm(options: LocalAudioTrackOptions) {
// nothing to do.
}

Expand All @@ -43,8 +45,15 @@ class NoAudioRecordPrewarmer : AudioRecordPrewarmer {
* @suppress
*/
class JavaAudioRecordPrewarmer(private val audioDeviceModule: JavaAudioDeviceModule) : AudioRecordPrewarmer {
override fun prewarm() {
audioDeviceModule.prewarmRecording()
override fun prewarm(options: LocalAudioTrackOptions) {
audioDeviceModule.prewarmRecording(
AudioProcessingOptions(
options.echoCancellation,
options.noiseSuppression,
options.autoGainControl,
options.highPassFilter,
),
)
}

override fun stop() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2025 LiveKit, Inc.
* Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -41,7 +41,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import livekit.LivekitModels.AudioTrackFeature
import livekit.org.webrtc.AudioTrackSink
Expand All @@ -50,6 +49,7 @@ import livekit.org.webrtc.PeerConnectionFactory
import livekit.org.webrtc.RtpSender
import livekit.org.webrtc.RtpTransceiver
import livekit.org.webrtc.audio.AudioDeviceModule
import livekit.org.webrtc.audio.AudioProcessingOptionsResult
import livekit.org.webrtc.audio.JavaAudioDeviceModule
import java.util.UUID
import javax.inject.Named
Expand All @@ -64,7 +64,7 @@ class LocalAudioTrack
constructor(
@Assisted name: String,
@Assisted mediaTrack: livekit.org.webrtc.AudioTrack,
@Assisted private val options: LocalAudioTrackOptions,
@Assisted options: LocalAudioTrackOptions,
private val audioProcessingController: AudioProcessingController,
@Named(InjectionNames.DISPATCHER_DEFAULT)
private val dispatcher: CoroutineDispatcher,
Expand All @@ -86,11 +86,22 @@ constructor(

private val trackSinks = mutableSetOf<AudioTrackSink>()

/**
* The current capture processing options for this track.
*
* Changes can be observed by using [io.livekit.android.util.flow]
*/
@FlowObservable
@get:FlowObservable
var options: LocalAudioTrackOptions by flowDelegate(options)

/**
* Prewarms the audio stack if needed by starting the recording regardless of whether it's being published.
*
* Platform AEC/NS are configured from [LocalAudioTrackOptions] before the audio session starts.
*/
fun prewarm() {
audioRecordPrewarmer.prewarm()
audioRecordPrewarmer.prewarm(options)
}

fun stopPrewarm() {
Expand Down Expand Up @@ -129,30 +140,58 @@ constructor(
audioBufferCallbackDispatcher.bufferCallback = callback
}

/**
* Updates the capture processing options on this track.
*
* Note: [LocalAudioTrackOptions.typingNoiseDetection] is only applied at track creation time.
*
* Example:
* ```
* val track = localParticipant.getTrackPublication(Track.Source.MICROPHONE)?.track as? LocalAudioTrack
* track?.applyOptions(LocalAudioTrackOptions(echoCancellation = false))
* ```
*/
fun applyOptions(options: LocalAudioTrackOptions): Result<Unit> {
val result = withRTCTrack(null as AudioProcessingOptionsResult?) {
(this as livekit.org.webrtc.AudioTrack).setAudioProcessingOptions(options.toAudioProcessingOptions())
} ?: return Result.failure(
TrackException.InvalidTrackStateException("Cannot apply options to a disposed track"),
)

if (!result.isSuccess) {
return Result.failure(
TrackException.MediaException(
result.message?.takeIf { it.isNotEmpty() }
?: "Failed to apply audio processing options (${result.code})",
),
)
}

this.options = options
return Result.success(Unit)
}

/**
* Changes can be observed by using [io.livekit.android.util.flow]
*/
@FlowObservable
@get:FlowObservable
val features by flowDelegate(
stateFlow = combine(
::options.flow,
audioProcessingController::capturePostProcessor.flow,
audioProcessingController::bypassCapturePostProcessing.flow,
) { processor, bypass ->
processor to bypass
}
.map {
val features = getConstantFeatures()
val (processor, bypass) = it
if (!bypass && processor?.getName() == "krisp_noise_cancellation") {
features.add(AudioTrackFeature.TF_ENHANCED_NOISE_CANCELLATION)
}
return@map features
) { opts, processor, bypass ->
val features = getConstantFeatures(opts)
if (!bypass && processor?.getName() == "krisp_noise_cancellation") {
features.add(AudioTrackFeature.TF_ENHANCED_NOISE_CANCELLATION)
}
features
}
.stateIn(delegateScope, SharingStarted.Eagerly, emptySet()),
)

private fun getConstantFeatures(): MutableSet<AudioTrackFeature> {
private fun getConstantFeatures(options: LocalAudioTrackOptions): MutableSet<AudioTrackFeature> {
val features = mutableSetOf<AudioTrackFeature>()

if (options.echoCancellation) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 LiveKit, Inc.
* Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,10 +16,21 @@

package io.livekit.android.room.track

import livekit.org.webrtc.audio.AudioProcessingOptions

data class LocalAudioTrackOptions(
val noiseSuppression: Boolean = true,
val echoCancellation: Boolean = true,
val autoGainControl: Boolean = true,
val highPassFilter: Boolean = true,
val typingNoiseDetection: Boolean = true,
)

internal fun LocalAudioTrackOptions.toAudioProcessingOptions(): AudioProcessingOptions {
return AudioProcessingOptions(
echoCancellation,
noiseSuppression,
autoGainControl,
highPassFilter,
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 LiveKit, Inc.
* Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,6 +17,8 @@
package io.livekit.android.test.mock

import livekit.org.webrtc.AudioTrack
import livekit.org.webrtc.audio.AudioProcessingOptions
import livekit.org.webrtc.audio.AudioProcessingOptionsResult

class MockAudioStreamTrack(
val id: String = "id",
Expand All @@ -26,6 +28,13 @@ class MockAudioStreamTrack(
) : AudioTrack(1L) {

var disposed = false
var lastAudioProcessingOptions: AudioProcessingOptions? = null
var audioProcessingOptionsResult: AudioProcessingOptionsResult = AudioProcessingOptionsResult.stored()

override fun setAudioProcessingOptions(options: AudioProcessingOptions): AudioProcessingOptionsResult {
lastAudioProcessingOptions = options
return audioProcessingOptionsResult
}

override fun id(): String = id

Expand All @@ -49,6 +58,8 @@ class MockAudioStreamTrack(
disposed = true
}

override fun isDisposed(): Boolean = disposed

override fun setVolume(volume: Double) {
}
}
Loading