package com.peachpasswords.sync import android.annotation.SuppressLint import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * Real Android audio transport for Chirp pairing — the platform half of the * ggwave bridge. Speaker out via [AudioTrack] and mic in via [AudioRecord], * both float PCM at 48 kHz mono (ggwave's native rate). The codec that turns * payload bytes into waveforms is injected separately (see [ChirpCodecFactory]); * the native ggwave binding is the remaining NDK step, but the audio I/O this * file provides is production-real. * * Requires RECORD_AUDIO; [openPcmFloatAudioIO] raises a typed * [ChirpExchangeError] when the mic can't initialise (permission denied or * device busy) so the pairing flow surfaces MIC_DENIED rather than crashing. */ private const val SAMPLE_RATE = 48_000 @SuppressLint("MissingPermission") // Caller gates on RECORD_AUDIO; failure → MIC_DENIED below. suspend fun openPcmFloatAudioIO(): AudioIO = withContext(Dispatchers.IO) { val minIn = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT, ) if (minIn <= 0) { throw ChirpExchangeError(ChirpExchangeErrorKind.AUDIO_UNAVAILABLE, "mic buffer size unavailable") } val recordBuffer = maxOf(minIn, SAMPLE_RATE / 4 * 4) val record = try { AudioRecord( MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT, recordBuffer, ) } catch (e: Exception) { throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, e.message ?: "mic init failed") } if (record.state != AudioRecord.STATE_INITIALIZED) { record.release() throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, "mic failed to initialise") } val minOut = AudioTrack.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT, ) val track = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(), ) .setAudioFormat( AudioFormat.Builder() .setSampleRate(SAMPLE_RATE) .setEncoding(AudioFormat.ENCODING_PCM_FLOAT) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build(), ) .setBufferSizeInBytes(maxOf(minOut, 4096)) .setTransferMode(AudioTrack.MODE_STREAM) .build() record.startRecording() object : AudioIO { override val sampleRate = SAMPLE_RATE private val readBuf = FloatArray(recordBuffer / 4) override suspend fun play(waveform: FloatArray) = withContext(Dispatchers.IO) { if (track.playState != AudioTrack.PLAYSTATE_PLAYING) track.play() var offset = 0 while (offset < waveform.size) { val n = track.write(waveform, offset, waveform.size - offset, AudioTrack.WRITE_BLOCKING) if (n < 0) break offset += n } } override suspend fun pullSamples(): FloatArray = withContext(Dispatchers.IO) { val n = record.read(readBuf, 0, readBuf.size, AudioRecord.READ_BLOCKING) if (n <= 0) FloatArray(0) else readBuf.copyOf(n) } override suspend fun close(): Unit = withContext(Dispatchers.IO) { runCatching { record.stop() } runCatching { record.release() } runCatching { track.stop() } runCatching { track.release() } Unit } } }