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: 4 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<uses-permission android:name="android.permission.MODIFYAUDIOSETTINGS" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />

<application
android:name=".AvaApplication"
android:allowBackup="true"
Expand Down Expand Up @@ -69,4 +72,4 @@
</activity>
</application>

</manifest>
</manifest>
4 changes: 3 additions & 1 deletion app/src/main/java/com/example/ava/AvaApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
106 changes: 106 additions & 0 deletions app/src/main/java/com/example/ava/audio/BluetoothMonitor.kt
Original file line number Diff line number Diff line change
@@ -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}")
}
}
}
92 changes: 92 additions & 0 deletions app/src/main/java/com/example/ava/audio/BluetoothScoController.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Int> = flowOf(DEFAULT_AUDIO_SOURCE),
audioMode: Flow<Int> = flowOf(DEFAULT_AUDIO_MODE),
Expand All @@ -31,6 +35,7 @@ fun audioRecordMicrophoneFlow(
useSpeakerphone
) { audioSource, audioMode, useSpeakerPhone ->
AudioRecordMicrophone(
context = context,
audioManager = audioManager,
audioSource = audioSource,
audioMode = audioMode,
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -110,4 +158,4 @@ class AudioRecordMicrophone(
clearCommunicationDevice()
}
}
}
}
Loading