Coverage Summary for Class: ExportToAudioFile (com.vsevolodganin.clicktrack.export)
Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
ExportToAudioFile |
0%
(0/2)
|
0%
(0/101)
|
0%
(0/84)
|
0%
(0/712)
|
ExportToAudioFile$Companion |
|
ExportToAudioFile$export$1 |
|
ExportToAudioFile$export$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/7)
|
Total |
0%
(0/3)
|
0%
(0/101)
|
0%
(0/85)
|
0%
(0/719)
|
package com.vsevolodganin.clicktrack.export
import android.app.Application
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaCodecList
import android.media.MediaFormat
import android.media.MediaMuxer
import android.os.Build
import com.vsevolodganin.clicktrack.model.ClickTrack
import com.vsevolodganin.clicktrack.player.toPlayerEvents
import com.vsevolodganin.clicktrack.primitiveaudio.PrimitiveAudioMonoRenderer
import com.vsevolodganin.clicktrack.primitiveaudio.convertDurationToSamplesNumber
import com.vsevolodganin.clicktrack.primitiveaudio.convertSamplesNumberToDuration
import com.vsevolodganin.clicktrack.soundlibrary.SoundSourceProvider
import com.vsevolodganin.clicktrack.soundlibrary.UserSelectedSounds
import com.vsevolodganin.clicktrack.utils.log.Logger
import com.vsevolodganin.clicktrack.utils.media.pcmEncoding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import me.tatarka.inject.annotations.Inject
import java.io.File
import java.nio.ByteOrder
@Inject
class ExportToAudioFile(
private val application: Application,
primitiveAudioMonoRendererFactory: (targetSampleRate: Int) -> PrimitiveAudioMonoRenderer,
private val userSelectedSounds: UserSelectedSounds,
private val logger: Logger,
) {
private val primitiveAudioMonoRenderer = primitiveAudioMonoRendererFactory(SAMPLE_RATE)
suspend fun export(clickTrack: ClickTrack, reportProgress: suspend (Float) -> Unit): File? {
val soundSourceProvider = SoundSourceProvider(userSelectedSounds.get())
var muxer: MediaMuxer? = null
var codec: MediaCodec? = null
var outputFile: File? = null
return try {
var outputFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNEL_COUNT)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_FLOAT)
}
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
}
outputFile = withContext(Dispatchers.IO) {
File.createTempFile("click_track_export", ".m4a", application.cacheDir)
}
muxer = MediaMuxer(outputFile.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val codecName = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(outputFormat)!!
codec = MediaCodec.createByCodecName(codecName).apply {
configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
outputFormat = getOutputFormat()
}
val trackSampleSequence = primitiveAudioMonoRenderer.renderToMonoSamples(clickTrack.toPlayerEvents(), soundSourceProvider)
val trackSampleIterator = trackSampleSequence.iterator()
val samplesToWrite = convertDurationToSamplesNumber(clickTrack.durationInTime, SAMPLE_RATE)
val bufferInfo = MediaCodec.BufferInfo()
var samplesWritten = 0
var endOfInput = false
var endOfOutput = false
val coroutineContext = currentCoroutineContext()
while (coroutineContext.isActive && (!endOfInput || !endOfOutput)) {
if (!endOfInput) {
val inputBufferIndex = codec.dequeueInputBuffer(0L)
if (inputBufferIndex >= 0) {
val inputBuffer = codec.getInputBuffer(inputBufferIndex)!!
val presentationTimeUs = convertSamplesNumberToDuration(samplesWritten, SAMPLE_RATE).inWholeMicroseconds
val bytesWritten = when (outputFormat.pcmEncoding()) {
AudioFormat.ENCODING_PCM_FLOAT -> {
val sampleBuffer = inputBuffer.order(ByteOrder.nativeOrder()).asFloatBuffer()
while (trackSampleIterator.hasNext() && sampleBuffer.hasRemaining()) {
sampleBuffer.put(trackSampleIterator.next())
}
samplesWritten += sampleBuffer.position()
sampleBuffer.position() * Float.SIZE_BYTES
}
AudioFormat.ENCODING_PCM_16BIT -> {
val sampleBuffer = inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer()
while (trackSampleIterator.hasNext() && sampleBuffer.hasRemaining()) {
val nextFloatSample = trackSampleIterator.next()
val nextShortSample = (nextFloatSample * Short.MAX_VALUE).toInt().toShort()
sampleBuffer.put(nextShortSample)
}
samplesWritten += sampleBuffer.position()
sampleBuffer.position() * Short.SIZE_BYTES
}
else -> {
logger.logError(TAG, "Unsupported encoding")
return null
}
}
endOfInput = !trackSampleIterator.hasNext()
codec.queueInputBuffer(
inputBufferIndex,
0,
bytesWritten,
presentationTimeUs,
if (endOfInput) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0,
)
reportProgress(samplesWritten.toFloat() / samplesToWrite)
}
}
if (!endOfOutput) {
val outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, 0L)
if (outputBufferIndex >= 0) {
val outputBuffer = codec.getOutputBuffer(outputBufferIndex)!!
muxer.writeSampleData(0, outputBuffer, bufferInfo)
codec.releaseOutputBuffer(outputBufferIndex, false)
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// Not using `outputFormat` because of https://developer.android.com/reference/android/media/MediaCodec#CSD
muxer.addTrack(codec.outputFormat)
muxer.start()
}
endOfOutput = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
}
}
if (coroutineContext.isActive) {
outputFile
} else {
outputFile?.delete()
null
}
} catch (t: Throwable) {
logger.logError(TAG, "Failed to export track", t)
outputFile?.delete()
null
} finally {
try {
codec?.stop()
} catch (t: Throwable) {
logger.logError(TAG, "Failed to stop codec", t)
} finally {
codec?.release()
}
try {
muxer?.stop()
} catch (t: Throwable) {
logger.logError(TAG, "Failed to stop muxer", t)
} finally {
muxer?.release()
}
}
}
private companion object {
const val TAG = "ExportToAudioFile"
private const val SAMPLE_RATE = 44100
private const val CHANNEL_COUNT = 1
private const val BIT_RATE = 96 * 1024
}
}