Created
October 19, 2024 08:04
-
-
Save Qw4z1/5a7153246769abd767b52ab7506584c7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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