Skip to content

Instantly share code, notes, and snippets.

@Qw4z1
Created October 19, 2024 08:04
Show Gist options
  • Save Qw4z1/5a7153246769abd767b52ab7506584c7 to your computer and use it in GitHub Desktop.
Save Qw4z1/5a7153246769abd767b52ab7506584c7 to your computer and use it in GitHub Desktop.
package se.sabumbi.vacapp.audio
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioRecorder
import platform.AVFAudio.AVAudioRecorderDelegateProtocol
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryRecord
import platform.AVFAudio.AVFormatIDKey
import platform.AVFAudio.AVNumberOfChannelsKey
import platform.AVFAudio.AVSampleRateKey
import platform.AVFAudio.setActive
import platform.CoreAudioTypes.kAudioFormatMPEG4AAC
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSError
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIApplication
import platform.UIKit.UIBackgroundTaskIdentifier
import platform.UIKit.UIBackgroundTaskInvalid
import platform.darwin.NSObject
import progressTickDuration
import se.sabumbi.vacapp.autoId
import kotlin.concurrent.Volatile
actual class AudioRecorder {
private var fileName: String? = null
private var id: String? = null
private var avAudioRecorder: AVAudioRecorder? = null
private var backgroundTask: UIBackgroundTaskIdentifier = UIBackgroundTaskInvalid
private lateinit var timerJob: Job
private val serviceScope = CoroutineScope(Dispatchers.IO)
private val timer = RecordingTimer(serviceScope, progressTickDuration.toLong())
private val _timerState = MutableStateFlow(0)
private val _recordingState = MutableStateFlow(
RecordingState.Default()
)
actual companion object {
@Volatile
private var instance: AudioRecorder? = null
actual fun getInstance(): AudioRecorder =
instance ?: AudioRecorder().also { instance = it }
}
@OptIn(ExperimentalForeignApi::class)
actual fun startRecording() {
var pointerError: CPointer<ObjCObjectVar<NSError?>>? = null
val dirUrl = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = pointerError
)
if (pointerError != null) throw Exception(pointerError.toString())
id = autoId()
val fileUrl = dirUrl?.URLByAppendingPathComponent(pathComponent = "$id.mp4")
?: throw Exception("Null NSApplicationDirectory")
fileName = fileUrl.absoluteString
val audioSession = AVAudioSession.sharedInstance()
try {
audioSession.setCategory(AVAudioSessionCategoryRecord, error = null)
} catch (e: Exception) {
println("Error setting category: $e")
}
val recorderSettings = mapOf<Any?, Any>(
AVFormatIDKey to kAudioFormatMPEG4AAC,
AVSampleRateKey to 44100.0,
AVNumberOfChannelsKey to 1,
)
var recorderError: CPointer<ObjCObjectVar<NSError?>>? = null
avAudioRecorder = AVAudioRecorder(fileUrl, recorderSettings, recorderError)
avAudioRecorder?.delegate = object : NSObject(), AVAudioRecorderDelegateProtocol {
override fun audioRecorderDidFinishRecording(
recorder: AVAudioRecorder,
successfully: Boolean
) {
println("Recording finished")
}
}
avAudioRecorder?.meteringEnabled = true
avAudioRecorder?.prepareToRecord()
audioSession.setActive(true, error = null)
avAudioRecorder?.record()
println("Recording started at: $fileUrl")
timerJob = serviceScope.launch {
var milliseconds = 0
timer.startTimer().collect {
milliseconds += progressTickDuration
val dec = pollDecibels()
val progress = _recordingState.value
_recordingState.value = progress.copy(
progress = milliseconds,
isRecording = true,
decibels = (progress.decibels + dec).takeLast(100)
)
_timerState.value = it
}
}
backgroundTask = UIApplication.sharedApplication.beginBackgroundTaskWithExpirationHandler {
UIApplication.sharedApplication.endBackgroundTask(backgroundTask)
backgroundTask = UIBackgroundTaskInvalid
}
UIApplication.sharedApplication.idleTimerDisabled = true
}
actual fun observeRecordingState(coroutineScope: CoroutineScope): StateFlow<RecordingState> {
return _recordingState
}
private fun pollDecibels(): Float {
avAudioRecorder?.updateMeters()
val decibels =
avAudioRecorder?.averagePowerForChannel(0U) ?: -160f // Use -160 as a fallback to represent silence
return ((decibels + 160) / 160).coerceIn(0f, 1f) // Normalize to 0..1 range and ensure it stays within bounds
}
@OptIn(ExperimentalForeignApi::class)
actual fun stopRecording(): AudioFileInfo {
UIApplication.sharedApplication.idleTimerDisabled = false
if (avAudioRecorder?.recording == true) {
resetTimer()
cancelBackgroundTask()
avAudioRecorder?.stop()
AVAudioSession.sharedInstance().setActive(false, error = null)
val info = AudioFileInfo(
id ?: "",
fileName ?: "",
0L,
0L,
AudioSpecs(0, 0, 0)
)
cleanup()
return info
} else {
throw IllegalStateException("Recording not started")
}
}
@OptIn(ExperimentalForeignApi::class)
actual fun discardRecording() {
if (avAudioRecorder?.recording == true) {
resetTimer()
cancelBackgroundTask()
avAudioRecorder?.stop()
AVAudioSession.sharedInstance().setActive(false, error = null)
NSFileManager.defaultManager.removeItemAtURL(avAudioRecorder?.url!!, error = null)
cleanup()
println("Recording discarded")
}
}
private fun cancelBackgroundTask() {
if (backgroundTask != UIBackgroundTaskInvalid) {
UIApplication.sharedApplication.endBackgroundTask(backgroundTask)
backgroundTask = UIBackgroundTaskInvalid
}
}
private fun resetTimer() {
_recordingState.value = RecordingState.Default()
timer.stopTimer()
_timerState.value = 0
if (::timerJob.isInitialized) {
timerJob.cancel()
}
}
private fun cleanup() {
avAudioRecorder?.delegate = null
avAudioRecorder = null
fileName = null
id = null
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment