AndroidAudioIO.kt

3.9 KB · raw

1package com.peachpasswords.sync23import android.annotation.SuppressLint4import android.media.AudioAttributes5import android.media.AudioFormat6import android.media.AudioManager7import android.media.AudioRecord8import android.media.AudioTrack9import android.media.MediaRecorder10import kotlinx.coroutines.Dispatchers11import kotlinx.coroutines.withContext1213/**14 * Real Android audio transport for Chirp pairing — the platform half of the15 * ggwave bridge. Speaker out via [AudioTrack] and mic in via [AudioRecord],16 * both float PCM at 48 kHz mono (ggwave's native rate). The codec that turns17 * payload bytes into waveforms is injected separately (see [ChirpCodecFactory]);18 * the native ggwave binding is the remaining NDK step, but the audio I/O this19 * file provides is production-real.20 *21 * Requires RECORD_AUDIO; [openPcmFloatAudioIO] raises a typed22 * [ChirpExchangeError] when the mic can't initialise (permission denied or23 * device busy) so the pairing flow surfaces MIC_DENIED rather than crashing.24 */25private const val SAMPLE_RATE = 48_0002627@SuppressLint("MissingPermission") // Caller gates on RECORD_AUDIO; failure → MIC_DENIED below.28suspend fun openPcmFloatAudioIO(): AudioIO = withContext(Dispatchers.IO) {29    val minIn = AudioRecord.getMinBufferSize(30        SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,31    )32    if (minIn <= 0) {33        throw ChirpExchangeError(ChirpExchangeErrorKind.AUDIO_UNAVAILABLE, "mic buffer size unavailable")34    }35    val recordBuffer = maxOf(minIn, SAMPLE_RATE / 4 * 4)36    val record = try {37        AudioRecord(38            MediaRecorder.AudioSource.MIC,39            SAMPLE_RATE,40            AudioFormat.CHANNEL_IN_MONO,41            AudioFormat.ENCODING_PCM_FLOAT,42            recordBuffer,43        )44    } catch (e: Exception) {45        throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, e.message ?: "mic init failed")46    }47    if (record.state != AudioRecord.STATE_INITIALIZED) {48        record.release()49        throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, "mic failed to initialise")50    }5152    val minOut = AudioTrack.getMinBufferSize(53        SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,54    )55    val track = AudioTrack.Builder()56        .setAudioAttributes(57            AudioAttributes.Builder()58                .setUsage(AudioAttributes.USAGE_MEDIA)59                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)60                .build(),61        )62        .setAudioFormat(63            AudioFormat.Builder()64                .setSampleRate(SAMPLE_RATE)65                .setEncoding(AudioFormat.ENCODING_PCM_FLOAT)66                .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)67                .build(),68        )69        .setBufferSizeInBytes(maxOf(minOut, 4096))70        .setTransferMode(AudioTrack.MODE_STREAM)71        .build()7273    record.startRecording()7475    object : AudioIO {76        override val sampleRate = SAMPLE_RATE77        private val readBuf = FloatArray(recordBuffer / 4)7879        override suspend fun play(waveform: FloatArray) = withContext(Dispatchers.IO) {80            if (track.playState != AudioTrack.PLAYSTATE_PLAYING) track.play()81            var offset = 082            while (offset < waveform.size) {83                val n = track.write(waveform, offset, waveform.size - offset, AudioTrack.WRITE_BLOCKING)84                if (n < 0) break85                offset += n86            }87        }8889        override suspend fun pullSamples(): FloatArray = withContext(Dispatchers.IO) {90            val n = record.read(readBuf, 0, readBuf.size, AudioRecord.READ_BLOCKING)91            if (n <= 0) FloatArray(0) else readBuf.copyOf(n)92        }9394        override suspend fun close(): Unit = withContext(Dispatchers.IO) {95            runCatching { record.stop() }96            runCatching { record.release() }97            runCatching { track.stop() }98            runCatching { track.release() }99            Unit100        }101    }102}