feat(android): add VoiceRecorder with AudioRecord PCM streaming
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".DroidClawApp"
|
android:name=".DroidClawApp"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.thisux.droidclaw.overlay
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures audio from the microphone and streams base64-encoded PCM chunks.
|
||||||
|
*
|
||||||
|
* Audio format: 16kHz, mono, 16-bit PCM (linear16).
|
||||||
|
* Chunks are emitted every ~100ms via the [onChunk] callback.
|
||||||
|
*/
|
||||||
|
class VoiceRecorder(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onChunk: (base64: String) -> Unit
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "VoiceRecorder"
|
||||||
|
private const val SAMPLE_RATE = 16000
|
||||||
|
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
private const val CHUNK_SIZE = 3200 // ~100ms at 16kHz mono 16-bit
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var recordingJob: Job? = null
|
||||||
|
|
||||||
|
val isRecording: Boolean get() = recordingJob?.isActive == true
|
||||||
|
|
||||||
|
fun hasPermission(context: Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (isRecording) return false
|
||||||
|
|
||||||
|
val bufferSize = maxOf(
|
||||||
|
AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT),
|
||||||
|
CHUNK_SIZE * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
val record = try {
|
||||||
|
AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
CHANNEL_CONFIG,
|
||||||
|
AUDIO_FORMAT,
|
||||||
|
bufferSize
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
Log.e(TAG, "AudioRecord failed to initialize")
|
||||||
|
record.release()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRecord = record
|
||||||
|
record.startRecording()
|
||||||
|
|
||||||
|
recordingJob = scope.launch(Dispatchers.IO) {
|
||||||
|
val buffer = ByteArray(CHUNK_SIZE)
|
||||||
|
while (isActive) {
|
||||||
|
val bytesRead = record.read(buffer, 0, CHUNK_SIZE)
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
val base64 = Base64.encodeToString(
|
||||||
|
buffer.copyOf(bytesRead),
|
||||||
|
Base64.NO_WRAP
|
||||||
|
)
|
||||||
|
onChunk(base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Recording started")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
recordingJob?.cancel()
|
||||||
|
recordingJob = null
|
||||||
|
audioRecord?.let {
|
||||||
|
try {
|
||||||
|
it.stop()
|
||||||
|
it.release()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error stopping AudioRecord", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioRecord = null
|
||||||
|
Log.i(TAG, "Recording stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user