Implement Audio Capture Infrastructure for Microphone Input

Resolved 💬 2 comments Opened Oct 20, 2025 by sanchitmonga22 Closed Oct 20, 2025

Priority

CRITICAL - Production Blocker

Context

Audio capture infrastructure is the foundation for all voice features. It provides a unified API for capturing microphone input across platforms. The iOS SDK has a complete AudioCaptureService with real-time streaming. The Kotlin SDK is missing this critical infrastructure.

Current State

  • Status: Infrastructure does not exist
  • Impact: Cannot capture audio from microphone for VAD, STT, or Wake Word components
  • iOS Has: Complete AudioCaptureService with AVAudioEngine integration

iOS Reference Implementation

  • Service: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Audio/AudioCaptureService.swift
  • Models: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Audio/Models/

iOS Architecture Pattern

public protocol AudioCaptureService: Sendable {
    func startCapture() async throws
    func stopCapture() async
    var audioStream: AsyncStream<Data> { get }
    var audioFormat: AudioFormat { get }
}

public final class AVAudioCaptureService: AudioCaptureService {
    private let audioEngine = AVAudioEngine()
    
    public func startCapture() async throws {
        let inputNode = audioEngine.inputNode
        let recordingFormat = inputNode.outputFormat(forBus: 0)
        
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, time in
            // Emit audio data
        }
        
        try audioEngine.start()
    }
}

Implementation Plan

1. Define Common Interfaces (commonMain)

// commonMain/kotlin/com/runanywhere/sdk/infrastructure/audio/AudioCaptureService.kt
interface AudioCaptureService {
    suspend fun startCapture()
    suspend fun stopCapture()
    val audioStream: Flow<ByteArray>
    val audioFormat: AudioFormat
    val isCapturing: Boolean
}

// commonMain/kotlin/com/runanywhere/sdk/infrastructure/audio/models/AudioFormat.kt
data class AudioFormat(
    val sampleRate: Int = 16000, // 16kHz default
    val channels: Int = 1, // Mono
    val encoding: AudioEncoding = AudioEncoding.PCM_16BIT
)

enum class AudioEncoding {
    PCM_8BIT,
    PCM_16BIT,
    PCM_24BIT,
    PCM_32BIT,
    FLOAT
}

2. Platform Implementations

Android (androidMain)
// androidMain/kotlin/com/runanywhere/sdk/infrastructure/audio/AndroidAudioCaptureService.kt
class AndroidAudioCaptureService(
    private val context: Context,
    override val audioFormat: AudioFormat = AudioFormat()
) : AudioCaptureService {
    
    private var audioRecord: AudioRecord? = null
    private var captureJob: Job? = null
    
    private val _audioStream = MutableSharedFlow<ByteArray>()
    override val audioStream: SharedFlow<ByteArray> = _audioStream.asSharedFlow()
    
    override var isCapturing: Boolean = false
        private set
    
    override suspend fun startCapture() {
        if (isCapturing) return
        
        // Check permission
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) 
            != PackageManager.PERMISSION_GRANTED) {
            throw SecurityException("RECORD_AUDIO permission not granted")
        }
        
        val bufferSize = AudioRecord.getMinBufferSize(
            audioFormat.sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT
        )
        
        audioRecord = AudioRecord(
            MediaRecorder.AudioSource.MIC,
            audioFormat.sampleRate,
            AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSize
        )
        
        audioRecord?.startRecording()
        isCapturing = true
        
        // Start capture loop
        captureJob = CoroutineScope(Dispatchers.IO).launch {
            val buffer = ByteArray(bufferSize)
            
            while (isCapturing) {
                val bytesRead = audioRecord?.read(buffer, 0, buffer.size) ?: 0
                if (bytesRead > 0) {
                    _audioStream.emit(buffer.copyOf(bytesRead))
                }
            }
        }
    }
    
    override suspend fun stopCapture() {
        isCapturing = false
        captureJob?.cancel()
        audioRecord?.stop()
        audioRecord?.release()
        audioRecord = null
    }
}
JVM (jvmMain)
// jvmMain/kotlin/com/runanywhere/sdk/infrastructure/audio/JvmAudioCaptureService.kt
class JvmAudioCaptureService(
    override val audioFormat: AudioFormat = AudioFormat()
) : AudioCaptureService {
    
    private var targetDataLine: TargetDataLine? = null
    private var captureJob: Job? = null
    
    private val _audioStream = MutableSharedFlow<ByteArray>()
    override val audioStream: SharedFlow<ByteArray> = _audioStream.asSharedFlow()
    
    override var isCapturing: Boolean = false
        private set
    
    override suspend fun startCapture() {
        if (isCapturing) return
        
        val format = javax.sound.sampled.AudioFormat(
            audioFormat.sampleRate.toFloat(),
            16, // 16-bit
            audioFormat.channels,
            true, // signed
            false // little-endian
        )
        
        val info = DataLine.Info(TargetDataLine::class.java, format)
        
        if (!AudioSystem.isLineSupported(info)) {
            throw UnsupportedOperationException("Audio format not supported")
        }
        
        targetDataLine = (AudioSystem.getLine(info) as TargetDataLine).apply {
            open(format)
            start()
        }
        
        isCapturing = true
        
        // Start capture loop
        captureJob = CoroutineScope(Dispatchers.IO).launch {
            val buffer = ByteArray(4096)
            
            while (isCapturing) {
                val bytesRead = targetDataLine?.read(buffer, 0, buffer.size) ?: 0
                if (bytesRead > 0) {
                    _audioStream.emit(buffer.copyOf(bytesRead))
                }
            }
        }
    }
    
    override suspend fun stopCapture() {
        isCapturing = false
        captureJob?.cancel()
        targetDataLine?.stop()
        targetDataLine?.close()
        targetDataLine = null
    }
}

3. Service Registration

// commonMain/kotlin/com/runanywhere/sdk/foundation/ServiceContainer.kt
class ServiceContainer {
    val audioCaptureService: AudioCaptureService by lazy {
        createAudioCaptureService()
    }
    
    private fun createAudioCaptureService(): AudioCaptureService {
        return platformAudioCaptureService()
    }
}

// expect/actual pattern
expect fun platformAudioCaptureService(): AudioCaptureService

Files to Create/Modify

New Files (commonMain)

  • [ ] infrastructure/audio/AudioCaptureService.kt
  • [ ] infrastructure/audio/models/AudioFormat.kt
  • [ ] infrastructure/audio/models/AudioEncoding.kt

New Files (androidMain)

  • [ ] infrastructure/audio/AndroidAudioCaptureService.kt

New Files (jvmMain)

  • [ ] infrastructure/audio/JvmAudioCaptureService.kt

Files to Modify

  • [ ] foundation/ServiceContainer.kt (add audio capture service)

Testing Requirements

  • [ ] Unit tests for AudioFormat
  • [ ] Android platform tests (AudioRecord integration)
  • [ ] JVM platform tests (javax.sound integration)
  • [ ] Stream backpressure tests
  • [ ] Permission handling tests (Android)
  • [ ] Multiple start/stop cycle tests
  • [ ] Audio format conversion tests

Success Criteria

  • [ ] Service captures audio on Android using AudioRecord
  • [ ] Service captures audio on JVM using javax.sound
  • [ ] Audio stream emits PCM data at configured sample rate
  • [ ] Proper permission handling on Android
  • [ ] Graceful start/stop lifecycle
  • [ ] Low latency audio capture (<100ms)
  • [ ] Thread-safe implementation
  • [ ] Integrated into ServiceContainer

Estimated Effort

1-2 weeks (80-100 hours)

  • Common interface design: 1-2 days
  • Android implementation: 3-4 days
  • JVM implementation: 3-4 days
  • Testing: 2-3 days
  • Documentation: 1 day

Dependencies

  • Depends on: #152 (Voice Capability Service for permission checks)
  • Depends on: #147 (privacy-first logging)
  • Blocks: VADComponent (needs audio stream)
  • Blocks: STTComponent (needs audio stream)
  • Blocks: #150 (Wake Word Detection)

Related Issues

  • Part of audio infrastructure foundation
  • Required for all voice input features
  • Aligns with iOS AudioCaptureService architecture

🤖 Generated with Claude Code

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗