From 675e41d4e4773de580ebef9e3d7016cca035236c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 26 May 2026 13:32:34 +0000 Subject: [PATCH] Added BT SCO provider and reconnect monitor --- app/src/main/AndroidManifest.xml | 5 +- .../java/com/example/ava/AvaApplication.kt | 4 +- .../com/example/ava/audio/BluetoothMonitor.kt | 106 ++++++++++++++++ .../ava/audio/BluetoothScoController.kt | 92 ++++++++++++++ .../microphone/.AudioRecordMicrophone.kt.swp | Bin 0 -> 1024 bytes .../microphone/AudioRecordMicrophone.kt | 70 +++++++++-- .../microphone/AudioRecordMicrophone.kt.bu | 113 ++++++++++++++++++ .../com/example/ava/services/DeviceBuilder.kt | 17 ++- .../java/com/example/ava/utils/FileLogger.kt | 41 +++++++ gradle.properties | 4 +- gradlew | 0 11 files changed, 432 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/example/ava/audio/BluetoothMonitor.kt create mode 100644 app/src/main/java/com/example/ava/audio/BluetoothScoController.kt create mode 100644 app/src/main/java/com/example/ava/esphome/android/microphone/.AudioRecordMicrophone.kt.swp create mode 100644 app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt.bu create mode 100644 app/src/main/java/com/example/ava/utils/FileLogger.kt mode change 100644 => 100755 gradlew diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b83a63e..9cac349 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,9 @@ + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/example/ava/AvaApplication.kt b/app/src/main/java/com/example/ava/AvaApplication.kt index e079b42..a02aff2 100644 --- a/app/src/main/java/com/example/ava/AvaApplication.kt +++ b/app/src/main/java/com/example/ava/AvaApplication.kt @@ -3,11 +3,13 @@ package com.example.ava import android.app.Application import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +import com.example.ava.util.FileLogger @HiltAndroidApp class AvaApplication : Application() { override fun onCreate() { super.onCreate() Timber.plant(Timber.DebugTree()) +// FileLogger.init(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/ava/audio/BluetoothMonitor.kt b/app/src/main/java/com/example/ava/audio/BluetoothMonitor.kt new file mode 100644 index 0000000..2d30a3c --- /dev/null +++ b/app/src/main/java/com/example/ava/audio/BluetoothMonitor.kt @@ -0,0 +1,106 @@ +package com.example.ava.audio + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.example.ava.util.FileLogger +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean + +class BluetoothMonitor( + private val context: Context, + private val scoController: BluetoothScoController +) { + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val startupTime = System.currentTimeMillis() + private val reconnecting = AtomicBoolean(false) + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + + BluetoothDevice.ACTION_ACL_DISCONNECTED -> { + val now = System.currentTimeMillis() + if (now - startupTime < 5000) { + FileLogger.log("BT device disconnected — starting reconnection loop") + return + } + FileLogger.log("BT device disconnected - starting reconnection loop") + startReconnectionLoop() + } + + BluetoothDevice.ACTION_ACL_CONNECTED -> { + FileLogger.log("BT device connected — enabling SCO") + reconnecting.set(false) + scoController.enableScoIfPossible() + } + } + } + } + + fun start() { + val filter = IntentFilter().apply { + addAction(BluetoothDevice.ACTION_ACL_CONNECTED) + addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) + } + context.registerReceiver(receiver, filter) + FileLogger.log("BluetoothMonitor registered") + } + + fun stop() { + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) {} + scope.cancel() + FileLogger.log("BluetoothMonitor stopped") + } + + private fun startReconnectionLoop() { + if (reconnecting.getAndSet(true)) return + + scope.launch { + val adapter = BluetoothAdapter.getDefaultAdapter() + var attempts = 0 + + // First 5 minutes: retry every 10 seconds + while (attempts < 30 && reconnecting.get()) { + FileLogger.log("Reconnection attempt $attempts (10s interval)") + tryReconnect(adapter) + delay(10_000) + attempts++ + } + + // After 5 minutes: retry every 5 minutes + while (reconnecting.get()) { + FileLogger.log("Reconnection attempt (5m interval)") + tryReconnect(adapter) + delay(5 * 60_000) + } + } + } + + private fun tryReconnect(adapter: BluetoothAdapter?) { + val device = adapter?.bondedDevices?.firstOrNull { + it.type == BluetoothDevice.DEVICE_TYPE_CLASSIC || + it.type == BluetoothDevice.DEVICE_TYPE_DUAL + } + + if (device == null) { + FileLogger.log("No bonded BT device found for reconnection") + return + } + + FileLogger.log("Attempting reconnect to ${device.name}") + + try { + val method = device.javaClass.getMethod("connect") + method.invoke(device) + } catch (e: Exception) { + FileLogger.log("Reconnect failed: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/example/ava/audio/BluetoothScoController.kt b/app/src/main/java/com/example/ava/audio/BluetoothScoController.kt new file mode 100644 index 0000000..ebaa613 --- /dev/null +++ b/app/src/main/java/com/example/ava/audio/BluetoothScoController.kt @@ -0,0 +1,92 @@ +package com.example.ava.audio + +import android.content.Context +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import com.example.ava.util.FileLogger + +class BluetoothScoController( + context: Context +) { + private var monitor: BluetoothMonitor? = null + private val audioManager: AudioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + @Volatile + private var scoRequested: Boolean = false + + fun enableScoIfPossible() { + // Set communication mode BEFORE requesting SCO + FileLogger.log("Setting MODE_IN_COMMUNICATION") + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + + audioManager.setStreamVolume( + AudioManager.STREAM_VOICE_CALL, + audioManager.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL),0 + ) + if (audioManager.isBluetoothScoOn || scoRequested) { + FileLogger.log("SCO already active or requested, skipping") + return + } + + scoRequested = true + + FileLogger.log("Scheduling delayed SCO start...") + + // Samsung requires a delay before SCO activation + Handler(Looper.getMainLooper()).postDelayed({ + try { + FileLogger.log("Requesting SCO start (delayed). isBluetoothScoOn=${audioManager.isBluetoothScoOn}") + + audioManager.startBluetoothSco() + audioManager.setBluetoothScoOn(true) + + FileLogger.log("SCO start requested. isBluetoothScoOn=${audioManager.isBluetoothScoOn}") + } catch (e: Exception) { + FileLogger.log("SCO start failed: ${e.message}") + scoRequested = false + } + }, 500) // 500ms delay required on Samsung + } + + fun waitForScoActive() { + // Call this BEFORE creating AudioRecord + var attempts = 0 + while (!audioManager.isBluetoothScoOn && attempts < 40) { // max 2 seconds + FileLogger.log("Waiting for SCO to become active... attempt=$attempts") + Thread.sleep(50) + attempts++ + } + FileLogger.log("SCO active state after wait: ${audioManager.isBluetoothScoOn}") + } + + fun disableScoIfActive() { + if (!scoRequested && !audioManager.isBluetoothScoOn) { + FileLogger.log("SCO not active, nothing to stop") + return + } + + try { + FileLogger.log("Requesting SCO stop. isBluetoothScoOn=${audioManager.isBluetoothScoOn}") + audioManager.stopBluetoothSco() + audioManager.setBluetoothScoOn(false) + monitor?.stop() + FileLogger.log("SCO stopped. isBluetoothScoOn=${audioManager.isBluetoothScoOn}") + } catch (e: Exception) { + FileLogger.log("Error stopping SCO: ${e.message}") + } finally { + scoRequested = false + + if (audioManager.mode == AudioManager.MODE_IN_COMMUNICATION) { + FileLogger.log("Resetting audio mode to NORMAL") + audioManager.mode = AudioManager.MODE_NORMAL + } + } + } + + fun attachMonitor(context: Context) { + monitor = BluetoothMonitor(context, this) + monitor?.start() + } +} diff --git a/app/src/main/java/com/example/ava/esphome/android/microphone/.AudioRecordMicrophone.kt.swp b/app/src/main/java/com/example/ava/esphome/android/microphone/.AudioRecordMicrophone.kt.swp new file mode 100644 index 0000000000000000000000000000000000000000..e60cbb7306a9095ba94eed0ed95376dcb2ac9197 GIT binary patch literal 1024 zcmYc?$V<%2SFq4CVL$=O%nS@g`S~R%f;c!yrI|S?y4m?9IhlDllz@d33kvj$i<0$o z6EpMlvl7b^^^@~+^;0Vna|?1(^+Ej9;)0C)Tp&9yr6@l$ML#z)xhNkfl$WaSSelZV cAC#J$UzFkt7uCxq%7OSCH7Yk60wXvC01XT(G5`Po literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt b/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt index b74e36b..510576b 100644 --- a/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt +++ b/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt @@ -1,18 +1,21 @@ package com.example.ava.esphome.android.microphone import android.Manifest +import android.content.Context import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager import android.media.AudioRecord import android.media.MediaRecorder import androidx.annotation.RequiresPermission +import com.example.ava.audio.BluetoothScoController import com.example.ava.esphome.microphone.Microphone import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import timber.log.Timber import java.nio.ByteBuffer +import com.example.ava.util.FileLogger const val DEFAULT_AUDIO_SOURCE = MediaRecorder.AudioSource.VOICE_RECOGNITION const val DEFAULT_AUDIO_MODE = AudioManager.MODE_NORMAL @@ -21,6 +24,7 @@ const val DEFAULT_CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO const val DEFAULT_AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT fun audioRecordMicrophoneFlow( + context: Context, audioManager: AudioManager, audioSource: Flow = flowOf(DEFAULT_AUDIO_SOURCE), audioMode: Flow = flowOf(DEFAULT_AUDIO_MODE), @@ -31,6 +35,7 @@ fun audioRecordMicrophoneFlow( useSpeakerphone ) { audioSource, audioMode, useSpeakerPhone -> AudioRecordMicrophone( + context = context, audioManager = audioManager, audioSource = audioSource, audioMode = audioMode, @@ -39,44 +44,82 @@ fun audioRecordMicrophoneFlow( } class AudioRecordMicrophone( - val audioManager: AudioManager? = null, + val context: Context, + val audioManager: AudioManager, val audioSource: Int = DEFAULT_AUDIO_SOURCE, val audioMode: Int = DEFAULT_AUDIO_MODE, val useSpeakerphone: Boolean = false, val sampleRateInHz: Int = DEFAULT_SAMPLE_RATE_IN_HZ, val channelConfig: Int = DEFAULT_CHANNEL_CONFIG, - val audioFormat: Int = DEFAULT_AUDIO_FORMAT + val audioFormat: Int = DEFAULT_AUDIO_FORMAT, + + // NEW: microphone gain parameter + val micGain: Float = 3.0f ) : Microphone { + private val bufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) private val buffer = ByteBuffer.allocateDirect(bufferSize) private var audioRecord: AudioRecord? = null + private var scoController: BluetoothScoController? = null + + private fun resolveAudioSource(): Int { + return try { + MediaRecorder.AudioSource.VOICE_COMMUNICATION + } catch (_: Exception) { + audioSource + } + } + @RequiresPermission(Manifest.permission.RECORD_AUDIO) override fun start() { - audioManager?.apply { - mode = audioMode - setSpeakerphoneCompat(useSpeakerphone) + + audioManager?.let { am -> + am.mode = AudioManager.MODE_IN_COMMUNICATION + am.setSpeakerphoneCompat(useSpeakerphone) + + scoController = BluetoothScoController(context) + scoController?.attachMonitor(context) + scoController?.enableScoIfPossible() + scoController?.waitForScoActive() } + audioRecord = AudioRecord( - audioSource, + resolveAudioSource(), sampleRateInHz, channelConfig, audioFormat, bufferSize * 2 ).apply { + FileLogger.log("AudioRecord created, state=$state, sampleRate=$sampleRateInHz, bufferSize=$bufferSize") check(state == AudioRecord.STATE_INITIALIZED) { "Failed to initialize AudioRecord" } - Timber.d("Starting microphone") startRecording() + FileLogger.log("AudioRecord started, recordingState=$recordingState") } } override fun read(): ByteBuffer { audioRecord?.let { val read = it.read(buffer, bufferSize) + FileLogger.log("Mic read bytes: $read, recordingState=${it.recordingState}, state=${it.state}") check(read >= 0) { "error reading audio, read: $read" } - // AudioRecord.read ignores the position and limit - // of the buffer so manually update them. + + // ⭐ NEW: apply microphone gain + if (micGain != 1.0f && read > 0) { + for (i in 0 until read step 2) { + val sample = (buffer.get(i).toInt() or (buffer.get(i + 1).toInt() shl 8)).toShort() + var boosted = (sample * micGain).toInt() + + // clamp to 16‑bit PCM range + if (boosted > Short.MAX_VALUE) boosted = Short.MAX_VALUE.toInt() + if (boosted < Short.MIN_VALUE) boosted = Short.MIN_VALUE.toInt() + + buffer.put(i, (boosted and 0xFF).toByte()) + buffer.put(i + 1, ((boosted shr 8) and 0xFF).toByte()) + } + } + buffer.position(0) buffer.limit(read) } ?: error("Microphone not started") @@ -85,10 +128,15 @@ class AudioRecordMicrophone( override fun stop() { audioRecord?.let { + try { + it.stop() + } catch (_: Exception) {} it.release() audioRecord = null - Timber.d("Microphone stopped") } + + scoController?.disableScoIfActive() + audioManager?.apply { mode = AudioManager.MODE_NORMAL setSpeakerphoneCompat(false) @@ -110,4 +158,4 @@ class AudioRecordMicrophone( clearCommunicationDevice() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt.bu b/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt.bu new file mode 100644 index 0000000..b74e36b --- /dev/null +++ b/app/src/main/java/com/example/ava/esphome/android/microphone/AudioRecordMicrophone.kt.bu @@ -0,0 +1,113 @@ +package com.example.ava.esphome.android.microphone + +import android.Manifest +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.MediaRecorder +import androidx.annotation.RequiresPermission +import com.example.ava.esphome.microphone.Microphone +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import timber.log.Timber +import java.nio.ByteBuffer + +const val DEFAULT_AUDIO_SOURCE = MediaRecorder.AudioSource.VOICE_RECOGNITION +const val DEFAULT_AUDIO_MODE = AudioManager.MODE_NORMAL +const val DEFAULT_SAMPLE_RATE_IN_HZ = 16000 +const val DEFAULT_CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO +const val DEFAULT_AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + +fun audioRecordMicrophoneFlow( + audioManager: AudioManager, + audioSource: Flow = flowOf(DEFAULT_AUDIO_SOURCE), + audioMode: Flow = flowOf(DEFAULT_AUDIO_MODE), + useSpeakerphone: Flow = flowOf(false) +): Flow = combine( + audioSource, + audioMode, + useSpeakerphone +) { audioSource, audioMode, useSpeakerPhone -> + AudioRecordMicrophone( + audioManager = audioManager, + audioSource = audioSource, + audioMode = audioMode, + useSpeakerphone = useSpeakerPhone + ) +} + +class AudioRecordMicrophone( + val audioManager: AudioManager? = null, + val audioSource: Int = DEFAULT_AUDIO_SOURCE, + val audioMode: Int = DEFAULT_AUDIO_MODE, + val useSpeakerphone: Boolean = false, + val sampleRateInHz: Int = DEFAULT_SAMPLE_RATE_IN_HZ, + val channelConfig: Int = DEFAULT_CHANNEL_CONFIG, + val audioFormat: Int = DEFAULT_AUDIO_FORMAT +) : Microphone { + private val bufferSize = + AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) + private val buffer = ByteBuffer.allocateDirect(bufferSize) + private var audioRecord: AudioRecord? = null + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun start() { + audioManager?.apply { + mode = audioMode + setSpeakerphoneCompat(useSpeakerphone) + } + audioRecord = AudioRecord( + audioSource, + sampleRateInHz, + channelConfig, + audioFormat, + bufferSize * 2 + ).apply { + check(state == AudioRecord.STATE_INITIALIZED) { "Failed to initialize AudioRecord" } + Timber.d("Starting microphone") + startRecording() + } + } + + override fun read(): ByteBuffer { + audioRecord?.let { + val read = it.read(buffer, bufferSize) + check(read >= 0) { "error reading audio, read: $read" } + // AudioRecord.read ignores the position and limit + // of the buffer so manually update them. + buffer.position(0) + buffer.limit(read) + } ?: error("Microphone not started") + return buffer + } + + override fun stop() { + audioRecord?.let { + it.release() + audioRecord = null + Timber.d("Microphone stopped") + } + audioManager?.apply { + mode = AudioManager.MODE_NORMAL + setSpeakerphoneCompat(false) + } + } + + override fun close() { + stop() + } + + @Suppress("DEPRECATION") + private fun AudioManager.setSpeakerphoneCompat(enable: Boolean) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + isSpeakerphoneOn = enable + } else if (enable) { + availableCommunicationDevices.find { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } + ?.let { setCommunicationDevice(it) } + } else { + clearCommunicationDevice() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index 70f5dca..93bd805 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -28,6 +28,7 @@ import com.example.esphomeproto.api.deviceInfoResponse import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlin.coroutines.CoroutineContext +import android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION class DeviceBuilder @Inject constructor( @ApplicationContext private val context: Context, @@ -104,6 +105,7 @@ class DeviceBuilder @Inject constructor( ) private fun AudioProcessingSettingsStore.toMicrophone() = audioRecordMicrophoneFlow( + context = context, audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager, audioSource = audioSource, audioMode = audioMode, @@ -112,13 +114,16 @@ class DeviceBuilder @Inject constructor( private suspend fun PlayerSettingsStore.toVoiceOutput(): VoiceOutputImpl { val playerSettings = get() - return VoiceOutputImpl( - ttsPlayer = context.media3MediaPlayer( - USAGE_ASSISTANT, + val tts = context.media3MediaPlayer( + USAGE_VOICE_COMMUNICATION, // force SCO routing AUDIO_CONTENT_TYPE_SPEECH, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + ) + // BOOST SCO OUTPUT (Jabra + Samsung attenuate SCO heavily) + tts.volume = 3.0f // 200% gain — increase to 3.0f if needed - ), + return VoiceOutputImpl( + ttsPlayer = tts, mediaPlayer = context.media3MediaPlayer( USAGE_MEDIA, AUDIO_CONTENT_TYPE_MUSIC, @@ -134,4 +139,4 @@ class DeviceBuilder @Inject constructor( muted = playerSettings.muted, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/ava/utils/FileLogger.kt b/app/src/main/java/com/example/ava/utils/FileLogger.kt new file mode 100644 index 0000000..818cc04 --- /dev/null +++ b/app/src/main/java/com/example/ava/utils/FileLogger.kt @@ -0,0 +1,41 @@ +package com.example.ava.util + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object FileLogger { + + private var logUri: Uri? = null + private var outputStream: OutputStream? = null + + fun init(context: Context) { + val resolver = context.contentResolver + + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, "ava_debug_log.txt") + put(MediaStore.Downloads.MIME_TYPE, "text/plain") + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + + logUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + outputStream = logUri?.let { resolver.openOutputStream(it, "wa") } + + log("=== Logger initialized at ${Date()} ===") + } + + fun log(message: String) { + try { + val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + outputStream?.write("$timestamp $message\n".toByteArray()) + outputStream?.flush() + } catch (_: Exception) { + } + } +} diff --git a/gradle.properties b/gradle.properties index 20e2a01..1f55a16 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +versionCode=169 +versionName="0.6.1" diff --git a/gradlew b/gradlew old mode 100644 new mode 100755