davidliu
Committed by GitHub

Communication workaround for Android 11+ (#363) (#364)

* Communication mode workaround

* clean up implementation

* tests and spotless

* Inject dispatcher

* Tie communication workaround to playout instead of record
@@ -104,6 +104,22 @@ class AudioOptions( @@ -104,6 +104,22 @@ class AudioOptions(
104 * Not used if [audioDeviceModule] is provided. 104 * Not used if [audioDeviceModule] is provided.
105 */ 105 */
106 val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, 106 val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null,
  107 +
  108 + /**
  109 + * On Android 11+, the audio mode will reset itself from [AudioManager.MODE_IN_COMMUNICATION] if
  110 + * there is no audio playback or capture for 6 seconds (for example when joining a room with
  111 + * no speakers and the local mic is muted.) This mode reset will cause unexpected
  112 + * behavior when trying to change the volume, causing it to not properly change the volume.
  113 + *
  114 + * We use a workaround by playing a silent audio track to keep the communication mode from
  115 + * resetting.
  116 + *
  117 + * Setting this flag to true will disable the workaround.
  118 + *
  119 + * This flag is a no-op when the audio mode is set to anything other than
  120 + * [AudioManager.MODE_IN_COMMUNICATION].
  121 + */
  122 + val disableCommunicationModeWorkaround: Boolean = false,
107 ) 123 )
108 124
109 /** 125 /**
  1 +/*
  2 + * Copyright 2024 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.audio
  18 +
  19 +import android.annotation.SuppressLint
  20 +import android.media.AudioAttributes
  21 +import android.media.AudioFormat
  22 +import android.media.AudioManager
  23 +import android.media.AudioTrack
  24 +import android.os.Build
  25 +import androidx.annotation.RequiresApi
  26 +import io.livekit.android.dagger.InjectionNames
  27 +import io.livekit.android.util.CloseableCoroutineScope
  28 +import kotlinx.coroutines.MainCoroutineDispatcher
  29 +import kotlinx.coroutines.flow.MutableStateFlow
  30 +import kotlinx.coroutines.flow.combine
  31 +import kotlinx.coroutines.flow.distinctUntilChanged
  32 +import kotlinx.coroutines.launch
  33 +import java.nio.ByteBuffer
  34 +import javax.inject.Inject
  35 +import javax.inject.Named
  36 +import javax.inject.Singleton
  37 +
  38 +/**
  39 + * @see CommunicationWorkaroundImpl
  40 + */
  41 +interface CommunicationWorkaround {
  42 +
  43 + fun start()
  44 + fun stop()
  45 + fun onStartPlayout()
  46 + fun onStopPlayout()
  47 +
  48 + fun dispose()
  49 +}
  50 +
  51 +class NoopCommunicationWorkaround
  52 +@Inject
  53 +constructor() : CommunicationWorkaround {
  54 + override fun start() {
  55 + }
  56 +
  57 + override fun stop() {
  58 + }
  59 +
  60 + override fun onStartPlayout() {
  61 + }
  62 +
  63 + override fun onStopPlayout() {
  64 + }
  65 +
  66 + override fun dispose() {
  67 + }
  68 +}
  69 +
  70 +/**
  71 + * Work around for communication mode resetting after 6 seconds if no audio playback or capture.
  72 + * Issue only happens on 11+ (version code R).
  73 + * https://issuetracker.google.com/issues/209493718
  74 + */
  75 +@Singleton
  76 +@RequiresApi(Build.VERSION_CODES.R)
  77 +class CommunicationWorkaroundImpl
  78 +@Inject
  79 +constructor(
  80 + @Named(InjectionNames.DISPATCHER_MAIN)
  81 + dispatcher: MainCoroutineDispatcher,
  82 +) : CommunicationWorkaround {
  83 +
  84 + private val coroutineScope = CloseableCoroutineScope(dispatcher)
  85 + private val started = MutableStateFlow(false)
  86 + private val playoutStopped = MutableStateFlow(true)
  87 +
  88 + private var audioTrack: AudioTrack? = null
  89 +
  90 + init {
  91 + coroutineScope.launch {
  92 + started.combine(playoutStopped) { a, b -> a to b }
  93 + .distinctUntilChanged()
  94 + .collect { (started, playoutStopped) ->
  95 + onStateChanged(started, playoutStopped)
  96 + }
  97 + }
  98 + }
  99 +
  100 + override fun start() {
  101 + started.value = true
  102 + }
  103 +
  104 + override fun stop() {
  105 + started.value = false
  106 + }
  107 +
  108 + override fun onStartPlayout() {
  109 + playoutStopped.value = false
  110 + }
  111 +
  112 + override fun onStopPlayout() {
  113 + playoutStopped.value = true
  114 + }
  115 +
  116 + @SuppressLint("NewApi")
  117 + private fun onStateChanged(started: Boolean, playoutStopped: Boolean) {
  118 + if (started && playoutStopped) {
  119 + startAudioTrackIfNeeded()
  120 + } else {
  121 + stopAudioTrackIfNeeded()
  122 + }
  123 + }
  124 +
  125 + @SuppressLint("Range")
  126 + private fun startAudioTrackIfNeeded() {
  127 + if (audioTrack != null) {
  128 + return
  129 + }
  130 +
  131 + val sampleRate = 16000
  132 + val audioFormat = AudioFormat.ENCODING_PCM_16BIT
  133 + val bytesPerFrame = 1 * getBytesPerSample(audioFormat)
  134 + val framesPerBuffer: Int = sampleRate / 100 // 10 ms
  135 + val byteBuffer = ByteBuffer.allocateDirect(bytesPerFrame * framesPerBuffer)
  136 +
  137 + audioTrack = AudioTrack.Builder()
  138 + .setAudioFormat(
  139 + AudioFormat.Builder()
  140 + .setEncoding(audioFormat)
  141 + .setSampleRate(sampleRate)
  142 + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
  143 + .build(),
  144 + )
  145 + .setAudioAttributes(
  146 + AudioAttributes.Builder()
  147 + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
  148 + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
  149 + .build(),
  150 + )
  151 + .setBufferSizeInBytes(byteBuffer.capacity())
  152 + .setTransferMode(AudioTrack.MODE_STATIC)
  153 + .setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE)
  154 + .build()
  155 +
  156 + audioTrack?.write(byteBuffer, byteBuffer.remaining(), AudioTrack.WRITE_BLOCKING)
  157 + audioTrack?.setLoopPoints(0, framesPerBuffer - 1, -1)
  158 +
  159 + audioTrack?.play()
  160 + }
  161 +
  162 + // Reference from Android code, AudioFormat.getBytesPerSample. BitPerSample / 8
  163 + // Default audio data format is PCM 16 bits per sample.
  164 + // Guaranteed to be supported by all devices
  165 + private fun getBytesPerSample(audioFormat: Int): Int {
  166 + return when (audioFormat) {
  167 + AudioFormat.ENCODING_PCM_8BIT -> 1
  168 + AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_IEC61937, AudioFormat.ENCODING_DEFAULT -> 2
  169 + AudioFormat.ENCODING_PCM_FLOAT -> 4
  170 + AudioFormat.ENCODING_INVALID -> throw IllegalArgumentException("Bad audio format $audioFormat")
  171 + else -> throw IllegalArgumentException("Bad audio format $audioFormat")
  172 + }
  173 + }
  174 +
  175 + private fun stopAudioTrackIfNeeded() {
  176 + audioTrack?.stop()
  177 + audioTrack?.release()
  178 + audioTrack = null
  179 + }
  180 +
  181 + override fun dispose() {
  182 + coroutineScope.close()
  183 + stop()
  184 + stopAudioTrackIfNeeded()
  185 + }
  186 +}
@@ -17,11 +17,17 @@ @@ -17,11 +17,17 @@
17 package io.livekit.android.dagger 17 package io.livekit.android.dagger
18 18
19 import android.media.AudioAttributes 19 import android.media.AudioAttributes
  20 +import android.media.AudioManager
  21 +import android.os.Build
20 import dagger.Module 22 import dagger.Module
21 import dagger.Provides 23 import dagger.Provides
22 import io.livekit.android.AudioType 24 import io.livekit.android.AudioType
23 import io.livekit.android.audio.AudioHandler 25 import io.livekit.android.audio.AudioHandler
24 import io.livekit.android.audio.AudioSwitchHandler 26 import io.livekit.android.audio.AudioSwitchHandler
  27 +import io.livekit.android.audio.CommunicationWorkaround
  28 +import io.livekit.android.audio.CommunicationWorkaroundImpl
  29 +import io.livekit.android.audio.NoopCommunicationWorkaround
  30 +import io.livekit.android.memory.CloseableManager
25 import javax.inject.Named 31 import javax.inject.Named
26 import javax.inject.Provider 32 import javax.inject.Provider
27 import javax.inject.Singleton 33 import javax.inject.Singleton
@@ -62,4 +68,27 @@ internal object AudioHandlerModule { @@ -62,4 +68,27 @@ internal object AudioHandlerModule {
62 audioStreamType = audioOutputType.audioStreamType 68 audioStreamType = audioOutputType.audioStreamType
63 } 69 }
64 } 70 }
  71 +
  72 + @Provides
  73 + @Singleton
  74 + @JvmSuppressWildcards
  75 + fun communicationWorkaround(
  76 + @Named(InjectionNames.OVERRIDE_DISABLE_COMMUNICATION_WORKAROUND)
  77 + disableCommunicationWorkaround: Boolean,
  78 + audioType: AudioType,
  79 + closeableManager: CloseableManager,
  80 + commWorkaroundImplProvider: Provider<CommunicationWorkaroundImpl>,
  81 + ): CommunicationWorkaround {
  82 + return if (
  83 + !disableCommunicationWorkaround &&
  84 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
  85 + audioType.audioMode == AudioManager.MODE_IN_COMMUNICATION
  86 + ) {
  87 + commWorkaroundImplProvider.get().apply {
  88 + closeableManager.registerClosable { this.dispose() }
  89 + }
  90 + } else {
  91 + NoopCommunicationWorkaround()
  92 + }
  93 + }
65 } 94 }
@@ -52,5 +52,6 @@ internal object InjectionNames { @@ -52,5 +52,6 @@ internal object InjectionNames {
52 internal const val OVERRIDE_VIDEO_DECODER_FACTORY = "override_video_decoder_factory" 52 internal const val OVERRIDE_VIDEO_DECODER_FACTORY = "override_video_decoder_factory"
53 internal const val OVERRIDE_AUDIO_HANDLER = "override_audio_handler" 53 internal const val OVERRIDE_AUDIO_HANDLER = "override_audio_handler"
54 internal const val OVERRIDE_AUDIO_OUTPUT_TYPE = "override_audio_output_type" 54 internal const val OVERRIDE_AUDIO_OUTPUT_TYPE = "override_audio_output_type"
  55 + internal const val OVERRIDE_DISABLE_COMMUNICATION_WORKAROUND = "override_disable_communication_workaround"
55 internal const val OVERRIDE_EGL_BASE = "override_egl_base" 56 internal const val OVERRIDE_EGL_BASE = "override_egl_base"
56 } 57 }
@@ -62,6 +62,10 @@ internal class OverridesModule(private val overrides: LiveKitOverrides) { @@ -62,6 +62,10 @@ internal class OverridesModule(private val overrides: LiveKitOverrides) {
62 fun audioOutputType() = overrides.audioOptions?.audioOutputType 62 fun audioOutputType() = overrides.audioOptions?.audioOutputType
63 63
64 @Provides 64 @Provides
  65 + @Named(InjectionNames.OVERRIDE_DISABLE_COMMUNICATION_WORKAROUND)
  66 + fun disableCommunicationWorkAround() = overrides.audioOptions?.disableCommunicationModeWorkaround ?: false
  67 +
  68 + @Provides
65 @Named(InjectionNames.OVERRIDE_EGL_BASE) 69 @Named(InjectionNames.OVERRIDE_EGL_BASE)
66 @Nullable 70 @Nullable
67 fun eglBase() = overrides.eglBase 71 fun eglBase() = overrides.eglBase
@@ -25,6 +25,7 @@ import androidx.annotation.Nullable @@ -25,6 +25,7 @@ import androidx.annotation.Nullable
25 import dagger.Module 25 import dagger.Module
26 import dagger.Provides 26 import dagger.Provides
27 import io.livekit.android.LiveKit 27 import io.livekit.android.LiveKit
  28 +import io.livekit.android.audio.CommunicationWorkaround
28 import io.livekit.android.memory.CloseableManager 29 import io.livekit.android.memory.CloseableManager
29 import io.livekit.android.util.LKLog 30 import io.livekit.android.util.LKLog
30 import io.livekit.android.util.LoggingLevel 31 import io.livekit.android.util.LoggingLevel
@@ -91,6 +92,7 @@ internal object RTCModule { @@ -91,6 +92,7 @@ internal object RTCModule {
91 audioOutputAttributes: AudioAttributes, 92 audioOutputAttributes: AudioAttributes,
92 appContext: Context, 93 appContext: Context,
93 closeableManager: CloseableManager, 94 closeableManager: CloseableManager,
  95 + communicationWorkaround: CommunicationWorkaround,
94 ): AudioDeviceModule { 96 ): AudioDeviceModule {
95 if (audioDeviceModuleOverride != null) { 97 if (audioDeviceModuleOverride != null) {
96 return audioDeviceModuleOverride 98 return audioDeviceModuleOverride
@@ -130,6 +132,7 @@ internal object RTCModule { @@ -130,6 +132,7 @@ internal object RTCModule {
130 LKLog.e { "onWebRtcAudioTrackError: $errorMessage" } 132 LKLog.e { "onWebRtcAudioTrackError: $errorMessage" }
131 } 133 }
132 } 134 }
  135 +
133 val audioRecordStateCallback: JavaAudioDeviceModule.AudioRecordStateCallback = object : 136 val audioRecordStateCallback: JavaAudioDeviceModule.AudioRecordStateCallback = object :
134 JavaAudioDeviceModule.AudioRecordStateCallback { 137 JavaAudioDeviceModule.AudioRecordStateCallback {
135 override fun onWebRtcAudioRecordStart() { 138 override fun onWebRtcAudioRecordStart() {
@@ -146,10 +149,12 @@ internal object RTCModule { @@ -146,10 +149,12 @@ internal object RTCModule {
146 JavaAudioDeviceModule.AudioTrackStateCallback { 149 JavaAudioDeviceModule.AudioTrackStateCallback {
147 override fun onWebRtcAudioTrackStart() { 150 override fun onWebRtcAudioTrackStart() {
148 LKLog.v { "Audio playout starts" } 151 LKLog.v { "Audio playout starts" }
  152 + communicationWorkaround.onStartPlayout()
149 } 153 }
150 154
151 override fun onWebRtcAudioTrackStop() { 155 override fun onWebRtcAudioTrackStop() {
152 LKLog.v { "Audio playout stops" } 156 LKLog.v { "Audio playout stops" }
  157 + communicationWorkaround.onStopPlayout()
153 } 158 }
154 } 159 }
155 160
@@ -31,6 +31,7 @@ import io.livekit.android.ConnectOptions @@ -31,6 +31,7 @@ import io.livekit.android.ConnectOptions
31 import io.livekit.android.RoomOptions 31 import io.livekit.android.RoomOptions
32 import io.livekit.android.Version 32 import io.livekit.android.Version
33 import io.livekit.android.audio.AudioHandler 33 import io.livekit.android.audio.AudioHandler
  34 +import io.livekit.android.audio.CommunicationWorkaround
34 import io.livekit.android.dagger.InjectionNames 35 import io.livekit.android.dagger.InjectionNames
35 import io.livekit.android.e2ee.E2EEManager 36 import io.livekit.android.e2ee.E2EEManager
36 import io.livekit.android.e2ee.E2EEOptions 37 import io.livekit.android.e2ee.E2EEOptions
@@ -69,6 +70,7 @@ constructor( @@ -69,6 +70,7 @@ constructor(
69 val audioHandler: AudioHandler, 70 val audioHandler: AudioHandler,
70 private val closeableManager: CloseableManager, 71 private val closeableManager: CloseableManager,
71 private val e2EEManagerFactory: E2EEManager.Factory, 72 private val e2EEManagerFactory: E2EEManager.Factory,
  73 + private val communicationWorkaround: CommunicationWorkaround,
72 ) : RTCEngine.Listener, ParticipantListener { 74 ) : RTCEngine.Listener, ParticipantListener {
73 75
74 private lateinit var coroutineScope: CoroutineScope 76 private lateinit var coroutineScope: CoroutineScope
@@ -133,8 +135,16 @@ constructor( @@ -133,8 +135,16 @@ constructor(
133 var state: State by flowDelegate(State.DISCONNECTED) { new, old -> 135 var state: State by flowDelegate(State.DISCONNECTED) { new, old ->
134 if (new != old) { 136 if (new != old) {
135 when (new) { 137 when (new) {
136 - State.CONNECTING -> audioHandler.start()  
137 - State.DISCONNECTED -> audioHandler.stop() 138 + State.CONNECTING -> {
  139 + audioHandler.start()
  140 + communicationWorkaround.start()
  141 + }
  142 +
  143 + State.DISCONNECTED -> {
  144 + audioHandler.stop()
  145 + communicationWorkaround.stop()
  146 + }
  147 +
138 else -> {} 148 else -> {}
139 } 149 }
140 } 150 }
  1 +/*
  2 + * Copyright 2024 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.util
  18 +
  19 +import android.os.Handler
  20 +import android.os.Looper
  21 +
  22 +fun Handler.runOrPost(r: Runnable) {
  23 + if (Looper.myLooper() == this.looper) {
  24 + r.run()
  25 + } else {
  26 + post(r)
  27 + }
  28 +}
1 /* 1 /*
2 - * Copyright 2023 LiveKit, Inc. 2 + * Copyright 2023-2024 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -19,10 +19,15 @@ package io.livekit.android.mock.dagger @@ -19,10 +19,15 @@ package io.livekit.android.mock.dagger
19 import dagger.Binds 19 import dagger.Binds
20 import dagger.Module 20 import dagger.Module
21 import io.livekit.android.audio.AudioHandler 21 import io.livekit.android.audio.AudioHandler
  22 +import io.livekit.android.audio.CommunicationWorkaround
22 import io.livekit.android.audio.NoAudioHandler 23 import io.livekit.android.audio.NoAudioHandler
  24 +import io.livekit.android.audio.NoopCommunicationWorkaround
23 25
24 @Module 26 @Module
25 interface TestAudioHandlerModule { 27 interface TestAudioHandlerModule {
26 @Binds 28 @Binds
27 fun audioHandler(audioHandler: NoAudioHandler): AudioHandler 29 fun audioHandler(audioHandler: NoAudioHandler): AudioHandler
  30 +
  31 + @Binds
  32 + fun communicationWorkaround(communicationWorkaround: NoopCommunicationWorkaround): CommunicationWorkaround
28 } 33 }
@@ -23,6 +23,7 @@ import androidx.test.core.app.ApplicationProvider @@ -23,6 +23,7 @@ import androidx.test.core.app.ApplicationProvider
23 import androidx.test.platform.app.InstrumentationRegistry 23 import androidx.test.platform.app.InstrumentationRegistry
24 import io.livekit.android.assert.assertIsClassList 24 import io.livekit.android.assert.assertIsClassList
25 import io.livekit.android.audio.NoAudioHandler 25 import io.livekit.android.audio.NoAudioHandler
  26 +import io.livekit.android.audio.NoopCommunicationWorkaround
26 import io.livekit.android.coroutines.TestCoroutineRule 27 import io.livekit.android.coroutines.TestCoroutineRule
27 import io.livekit.android.e2ee.E2EEManager 28 import io.livekit.android.e2ee.E2EEManager
28 import io.livekit.android.events.* 29 import io.livekit.android.events.*
@@ -99,6 +100,7 @@ class RoomTest { @@ -99,6 +100,7 @@ class RoomTest {
99 audioHandler = NoAudioHandler(), 100 audioHandler = NoAudioHandler(),
100 closeableManager = CloseableManager(), 101 closeableManager = CloseableManager(),
101 e2EEManagerFactory = e2EEManagerFactory, 102 e2EEManagerFactory = e2EEManagerFactory,
  103 + communicationWorkaround = NoopCommunicationWorkaround(),
102 ) 104 )
103 } 105 }
104 106