Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/ios audio source #36

Merged
merged 3 commits into from
Aug 19, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.noiseplanet.noisecapture
import AndroidLogger

import org.koin.core.module.Module
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.noiseplanet.noisecapture.audio.AndroidAudioSource
import org.noiseplanet.noisecapture.audio.AudioSource
Expand All @@ -18,5 +19,9 @@ val platformModule: Module = module {
AndroidLogger(tag)
}

factory<AudioSource> { AndroidAudioSource(logger = get()) }
factory<AudioSource> {
AndroidAudioSource(logger = get {
parametersOf("AudioSource")
})
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.noiseplanet.noisecapture

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -53,6 +56,7 @@ fun NoiseCaptureApp() {
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.windowInsetsPadding(WindowInsets.navigationBars)
) {
composable(route = Route.Home.name) {
// TODO: Silently check for permissions and bypass this step if they are already all granted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class DefaultMeasurementService(
}
logger.debug("Starting recording audio samples...")
// Start recording and processing audio samples in a background thread
audioJob = coroutineScope.launch {
audioJob = coroutineScope.launch(Dispatchers.Default) {
audioSource.setup()
.flowOn(Dispatchers.Default)
.collect { audioSamples ->
Expand Down Expand Up @@ -131,8 +131,10 @@ class DefaultMeasurementService(
}

override fun stopRecordingAudio() {
audioJob?.cancel()
audioSource.release()
coroutineScope.launch(Dispatchers.Default) {
audioJob?.cancel()
audioSource.release()
}
}

override fun getAcousticIndicatorsFlow(): Flow<AcousticIndicatorsData> {
Expand Down
10 changes: 10 additions & 0 deletions composeApp/src/iosMain/kotlin/Platform.ios.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import org.noiseplanet.noisecapture.permission.Permission
import platform.UIKit.UIDevice

@Suppress("MatchingDeclarationName")
class IOSPlatform : Platform {

override val name: String =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion

override val requiredPermissions: List<Permission>
// We can't control laptop settings on the web so we don't
// check if location services are on. It will be part of the
// location background permission check.
get() = listOf(
Permission.RECORD_AUDIO,
// Permission.LOCATION_BACKGROUND
)
}

actual fun getPlatform(): Platform = IOSPlatform()
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
package org.noiseplanet.noisecapture

import org.koin.core.module.Module
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.noiseplanet.noisecapture.audio.AudioSource
import org.noiseplanet.noisecapture.audio.IOSAudioSource
import org.noiseplanet.noisecapture.log.Logger

/**
* Registers koin components specific to this platform
*/
val platformModule: Module = module {

factory<Logger> { params ->
val tag: String? = params.values.firstOrNull() as? String
IOSLogger(tag)
}

factory<AudioSource> {
IOSAudioSource(logger = get {
parametersOf("AudioSource")
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package org.noiseplanet.noisecapture.audio

import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.pointed
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import org.noiseplanet.noisecapture.log.Logger
import platform.AVFAudio.AVAudioEngine
import platform.AVFAudio.AVAudioPCMBuffer
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryRecord
import platform.AVFAudio.AVAudioTime
import platform.AVFAudio.sampleRate
import platform.AVFAudio.setActive
import platform.AVFAudio.setPreferredIOBufferDuration
import platform.AVFAudio.setPreferredSampleRate
import platform.Foundation.NSError
import platform.Foundation.NSTimeInterval
import platform.posix.uint32_t

/**
* iOS [AudioSource] implementation using [AVAudioEngine]
*
* [Swift documentation](https://developer.apple.com/documentation/avfaudio/avaudioengine)
*/
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
internal class IOSAudioSource(
private val logger: Logger,
) : AudioSource {

companion object {

const val SAMPLES_BUFFER_SIZE: uint32_t = 1024u
}

private val audioSamplesChannel = Channel<AudioSamples>(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)

private val audioSession = AVAudioSession.sharedInstance()
private var audioEngine: AVAudioEngine? = null


override suspend fun setup(): Flow<AudioSamples> {
try {
setupAudioSession()
} catch (e: IllegalStateException) {
logger.error("Error during audio source setup", e)
}

val audioEngine = AVAudioEngine()
val inputNode = audioEngine.inputNode
val busNumber: ULong = 0u // Mono input

inputNode.installTapOnBus(
bus = busNumber,
bufferSize = SAMPLES_BUFFER_SIZE,
format = inputNode.outputFormatForBus(busNumber),
) { buffer, audioTime ->
try {
processBuffer(buffer, audioTime)
} catch (e: IllegalArgumentException) {
logger.warning("Wrong buffer data received from AVAudioEngine. Skipping.", e)
}
}

try {
logger.debug("Starting AVAudioEngine...")
memScoped {
val error: ObjCObjectVar<NSError?> = alloc()
audioEngine.startAndReturnError(error.ptr)
checkNoError(error.value) { "Error while starting AVAudioEngine" }
}
logger.debug("AVAudioEngine is now running")
} catch (e: IllegalStateException) {
logger.error("Error setting up audio source", e)
}

// Keep a reference to audio engine to be able to stop it afterwards
this.audioEngine = audioEngine

return audioSamplesChannel.receiveAsFlow()
}

override fun release() {
// Stop audio engine...
audioEngine?.stop()
// ... and audio session
memScoped {
val error: ObjCObjectVar<NSError?> = alloc()
audioSession.setActive(
active = false,
error = error.ptr
)
checkNoError(error.value) { "Error while stopping AVAudioSession" }
}
}

override fun getMicrophoneLocation(): AudioSource.MicrophoneLocation {
return AudioSource.MicrophoneLocation.LOCATION_UNKNOWN
}

/**
* Process incoming audio buffer from [AVAudioEngine]
*
* @param buffer PCM audio buffer. [Apple docs](https://developer.apple.com/documentation/avfaudio/avaudiopcmbuffer/).
* @param audioTime Audio time object. [Apple docs](https://developer.apple.com/documentation/avfaudio/avaudiotime/).
*
* @throws IllegalStateException Thrown if the incoming data doesn't conform to what
* is expected by the shared audio code.
*/
private fun processBuffer(buffer: AVAudioPCMBuffer?, audioTime: AVAudioTime?) {
requireNotNull(buffer) { "Null buffer received" }
requireNotNull(audioTime) { "Null audio time receiver" }

// Buffer size provided to audio engine is a request but not a guarantee
val actualSamplesCount = buffer.frameLength.toInt()

buffer.floatChannelData?.let { channelData ->
// Convert native float buffer to a Kotlin FloatArray
val samplesBuffer = FloatArray(actualSamplesCount) { index ->
// Channel data is internally a pointer to a float array
// so we need to go through pointed.value to access the actual
// array and retrieve the element using index
channelData.pointed.value?.get(index) ?: 0f
}
// Send processed audio samples through Channel
audioSamplesChannel.trySend(
AudioSamples(
audioTime.hostTime.toLong(),
samplesBuffer,
audioTime.sampleRate.toInt(),
)
)
}
}

/**
* Setup and activate [AVAudioSession].
*/
private fun setupAudioSession() {
logger.debug("Starting AVAudioSession...")

memScoped {
val error: ObjCObjectVar<NSError?> = alloc()
audioSession.setCategory(
category = AVAudioSessionCategoryRecord,
error = error.ptr
)
checkNoError(error.value) { "Error while setting AVAudioSession category" }

val sampleRate = audioSession.sampleRate
audioSession.setPreferredSampleRate(sampleRate, error.ptr)
checkNoError(error.value) { "Error while setting AVAudioSession sample rate" }

val bufferDuration: NSTimeInterval =
1.0 / sampleRate * SAMPLES_BUFFER_SIZE.toDouble()
audioSession.setPreferredIOBufferDuration(bufferDuration, error.ptr)
checkNoError(error.value) { "Error while setting AVAudioSession buffer size" }

// TODO: Figure out how to add an observer for NSNotification.Name.AVAudioSessionInterruption
// so we can listen to external AVAudioSession interruptions
audioSession.setActive(
active = true,
error = error.ptr
)
checkNoError(error.value) { "Error while starting AVAudioSession" }
}
logger.debug("AVAudioSession is now active")
}

/**
* Checks an optional [NSError] and if it's not null, throws an [IllegalStateException] with
* a given message and the error's localized description
*
* @param error Optional [NSError]
* @param lazyMessage Provided error message
* @throws [IllegalStateException] If given [NSError] is not null.
*/
private fun checkNoError(error: NSError?, lazyMessage: () -> String) {
check(error == null) {
"${lazyMessage()}: ${error?.localizedDescription}"
}
}
}
2 changes: 1 addition & 1 deletion composeApp/src/wasmJsMain/kotlin/Platform.wasmjs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class WasmJSPlatform : Platform {
// location background permission check.
get() = listOf(
Permission.RECORD_AUDIO,
Permission.LOCATION_BACKGROUND
// Permission.LOCATION_BACKGROUND
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.noiseplanet.noisecapture

import org.koin.core.module.Module
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.noiseplanet.noisecapture.audio.AudioSource
import org.noiseplanet.noisecapture.audio.JsAudioSource
Expand All @@ -14,6 +15,8 @@ val platformModule: Module = module {
}

factory<AudioSource> {
JsAudioSource(logger = get())
JsAudioSource(logger = get {
parametersOf("AudioSource")
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ import org.noiseplanet.noisecapture.interop.ScriptProcessorNode
import org.noiseplanet.noisecapture.log.Logger
import org.w3c.dom.mediacapture.MediaStreamConstraints

const val SAMPLES_BUFFER_SIZE = 1024

/**
* TODO: Document, cleanup, use platform logger instead of println, get rid of force unwraps (!!)
*/
internal class JsAudioSource(
private val logger: Logger,
) : AudioSource {

companion object {

const val SAMPLES_BUFFER_SIZE = 1024
}

private var audioContext: AudioContext? = null
private var micNode: AudioNode? = null
private var scriptProcessorNode: ScriptProcessorNode? = null
Expand Down
Loading