-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Add mobile voice notes #7121
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
Merged
Merged
Add mobile voice notes #7121
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
3862546
Add mobile voice notes
klopez4212 6665cb9
Scope mobile voice notes to iOS
klopez4212 1573c5a
Merge remote-tracking branch 'origin/main' into kennylopez-mobile-voi…
klopez4212 3ab606e
Add Android voice note packaging
klopez4212 845c528
Address mobile voice note review feedback
12f975b
Finish mobile voice note review fixes
klopez4212 3a5e49f
Match desktop voice note behavior on mobile
klopez4212 f8e4475
Defer authenticated voice note downloads
klopez4212 9257c1b
Fix deferred voice note state races
klopez4212 2b03bc9
Harden mobile voice note recovery
klopez4212 d2a0727
Merge origin/main into kennylopez-mobile-voice-notes
klopez4212 aa383d1
Fix voice note playback and encoder recovery
klopez4212 7eee4a1
Allow pending voice note playback cancellation
klopez4212 88e3cdf
Harden voice note identity and media bounds
klopez4212 a377d34
Fence voice note startup and clean packaging failures
klopez4212 9a07df6
Address voice note review findings
klopez4212 4db279c
Address voice note review findings
klopez4212 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
270 changes: 270 additions & 0 deletions
270
mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidVoiceNotePackager.kt
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| package xyz.block.buzz.mobile | ||
|
|
||
| import android.media.MediaCodec | ||
| import android.media.MediaCodecInfo | ||
| import android.media.MediaExtractor | ||
| import android.media.MediaFormat | ||
| import android.media.MediaMuxer | ||
| import java.io.File | ||
| import java.nio.ByteBuffer | ||
| import java.util.UUID | ||
|
|
||
| internal object AndroidVoiceNotePackager { | ||
| private const val videoMimeType = "video/avc" | ||
| private const val videoWidth = 16 | ||
| private const val videoHeight = 16 | ||
| private const val videoBitRate = 8_000 | ||
| private const val videoFrameRate = 1 | ||
| private const val dequeueTimeoutUs = 10_000L | ||
| private const val encoderTimeoutNs = 30_000_000_000L | ||
| private const val copyBufferSize = 1024 * 1024 | ||
|
|
||
| fun packageForUpload( | ||
| sourcePath: String, | ||
| cacheDirectory: File, | ||
| ): String { | ||
| val source = File(sourcePath) | ||
| require(source.isFile) { "The recording could not be found." } | ||
|
|
||
| val audio = findAudioTrack(sourcePath) | ||
| require(audio.durationUs > 0) { "The recording has no playable audio." } | ||
| require(audio.mimeType == MediaFormat.MIMETYPE_AUDIO_AAC) { | ||
| "The recording is not AAC audio." | ||
| } | ||
|
|
||
| val videoFile = File(cacheDirectory, "${UUID.randomUUID()}-voice-note-video.mp4") | ||
| val outputFile = File(cacheDirectory, "${UUID.randomUUID()}.mp4") | ||
| try { | ||
| writeBlackVideoTrack(videoFile, audio.durationUs) | ||
| muxEnvelope( | ||
| audioPath = sourcePath, | ||
| audioTrackIndex = audio.index, | ||
| videoPath = videoFile.absolutePath, | ||
| outputPath = outputFile.absolutePath, | ||
| ) | ||
| return outputFile.absolutePath | ||
| } catch (error: Exception) { | ||
| outputFile.delete() | ||
| throw error | ||
| } finally { | ||
| videoFile.delete() | ||
| } | ||
| } | ||
|
|
||
| private data class AudioTrack( | ||
| val index: Int, | ||
| val durationUs: Long, | ||
| val mimeType: String, | ||
| ) | ||
|
|
||
| private fun findAudioTrack(sourcePath: String): AudioTrack { | ||
| val extractor = MediaExtractor() | ||
| try { | ||
| extractor.setDataSource(sourcePath) | ||
| for (index in 0 until extractor.trackCount) { | ||
| val format = extractor.getTrackFormat(index) | ||
| val mimeType = format.getString(MediaFormat.KEY_MIME) ?: continue | ||
| if (!mimeType.startsWith("audio/")) continue | ||
| val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) { | ||
| format.getLong(MediaFormat.KEY_DURATION) | ||
| } else { | ||
| 0L | ||
| } | ||
| return AudioTrack(index, durationUs, mimeType) | ||
| } | ||
| throw IllegalArgumentException("The recording does not contain an audio track.") | ||
| } finally { | ||
| extractor.release() | ||
| } | ||
| } | ||
|
|
||
| private fun writeBlackVideoTrack( | ||
| outputFile: File, | ||
| durationUs: Long, | ||
| ) { | ||
| val codec = MediaCodec.createEncoderByType(videoMimeType) | ||
| var muxer: MediaMuxer? = null | ||
| try { | ||
| val colorFormat = codec.codecInfo | ||
| .getCapabilitiesForType(videoMimeType) | ||
| .colorFormats | ||
| .firstOrNull { | ||
| it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible || | ||
| it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar || | ||
| it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar | ||
| } | ||
| ?: throw IllegalStateException("This device cannot create the voice note envelope.") | ||
| val format = MediaFormat.createVideoFormat( | ||
| videoMimeType, | ||
| videoWidth, | ||
| videoHeight, | ||
| ).apply { | ||
| setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat) | ||
| setInteger(MediaFormat.KEY_BIT_RATE, videoBitRate) | ||
| setInteger(MediaFormat.KEY_FRAME_RATE, videoFrameRate) | ||
| setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) | ||
| } | ||
| codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) | ||
| codec.start() | ||
|
|
||
| muxer = MediaMuxer( | ||
| outputFile.absolutePath, | ||
| MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4, | ||
| ) | ||
| val frame = blackYuv420Frame() | ||
| val frameTimesUs = longArrayOf(0L, durationUs) | ||
| val bufferInfo = MediaCodec.BufferInfo() | ||
| var nextFrame = 0 | ||
| var inputEnded = false | ||
| var outputEnded = false | ||
| var outputTrack = -1 | ||
| var muxerStarted = false | ||
| val encoderDeadlineNs = System.nanoTime() + encoderTimeoutNs | ||
|
|
||
| while (!outputEnded) { | ||
| check(System.nanoTime() - encoderDeadlineNs < 0) { | ||
| "Timed out creating the voice note envelope." | ||
| } | ||
| if (!inputEnded) { | ||
| val inputIndex = codec.dequeueInputBuffer(dequeueTimeoutUs) | ||
| if (inputIndex >= 0) { | ||
| val inputBuffer = codec.getInputBuffer(inputIndex) | ||
| ?: throw IllegalStateException("Unable to create the video envelope.") | ||
| inputBuffer.clear() | ||
| if (nextFrame < frameTimesUs.size) { | ||
| inputBuffer.put(frame) | ||
| codec.queueInputBuffer( | ||
| inputIndex, | ||
| 0, | ||
| frame.size, | ||
| frameTimesUs[nextFrame], | ||
| 0, | ||
| ) | ||
| nextFrame += 1 | ||
| } else { | ||
| codec.queueInputBuffer( | ||
| inputIndex, | ||
| 0, | ||
| 0, | ||
| durationUs + 1, | ||
| MediaCodec.BUFFER_FLAG_END_OF_STREAM, | ||
| ) | ||
| inputEnded = true | ||
| } | ||
| } | ||
| } | ||
|
|
||
| when (val outputIndex = codec.dequeueOutputBuffer(bufferInfo, dequeueTimeoutUs)) { | ||
| MediaCodec.INFO_TRY_AGAIN_LATER -> Unit | ||
| MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { | ||
| check(!muxerStarted) { "The video encoder format changed twice." } | ||
| outputTrack = muxer.addTrack(codec.outputFormat) | ||
| muxer.start() | ||
| muxerStarted = true | ||
| } | ||
| else -> if (outputIndex >= 0) { | ||
| val outputBuffer = codec.getOutputBuffer(outputIndex) | ||
| ?: throw IllegalStateException("Unable to read the video envelope.") | ||
| if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { | ||
| bufferInfo.size = 0 | ||
| } | ||
| if (bufferInfo.size > 0) { | ||
| check(muxerStarted && outputTrack >= 0) { | ||
| "The video envelope has no output format." | ||
| } | ||
| outputBuffer.position(bufferInfo.offset) | ||
| outputBuffer.limit(bufferInfo.offset + bufferInfo.size) | ||
| muxer.writeSampleData(outputTrack, outputBuffer, bufferInfo) | ||
| } | ||
| outputEnded = | ||
| bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 | ||
| codec.releaseOutputBuffer(outputIndex, false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (muxerStarted) muxer.stop() | ||
| } finally { | ||
| try { | ||
| codec.stop() | ||
| } catch (_: Exception) { | ||
| // Best-effort encoder cleanup after a packaging failure. | ||
| } | ||
| codec.release() | ||
| try { | ||
| muxer?.release() | ||
| } catch (_: Exception) { | ||
| // Best-effort muxer cleanup after a packaging failure. | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun blackYuv420Frame(): ByteArray { | ||
| val yPlaneSize = videoWidth * videoHeight | ||
| return ByteArray(yPlaneSize * 3 / 2) { index -> | ||
| if (index < yPlaneSize) 16 else 128.toByte() | ||
| } | ||
| } | ||
|
|
||
| private fun muxEnvelope( | ||
| audioPath: String, | ||
| audioTrackIndex: Int, | ||
| videoPath: String, | ||
| outputPath: String, | ||
| ) { | ||
| val audioExtractor = MediaExtractor() | ||
| val videoExtractor = MediaExtractor() | ||
| var muxer: MediaMuxer? = null | ||
| try { | ||
| audioExtractor.setDataSource(audioPath) | ||
| audioExtractor.selectTrack(audioTrackIndex) | ||
| videoExtractor.setDataSource(videoPath) | ||
| val videoTrackIndex = (0 until videoExtractor.trackCount).firstOrNull { index -> | ||
| videoExtractor.getTrackFormat(index) | ||
| .getString(MediaFormat.KEY_MIME) | ||
| ?.startsWith("video/") == true | ||
| } ?: throw IllegalStateException("The video envelope has no video track.") | ||
| videoExtractor.selectTrack(videoTrackIndex) | ||
|
|
||
| muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) | ||
| val destinationVideoTrack = muxer.addTrack( | ||
| videoExtractor.getTrackFormat(videoTrackIndex), | ||
| ) | ||
| val destinationAudioTrack = muxer.addTrack( | ||
| audioExtractor.getTrackFormat(audioTrackIndex), | ||
| ) | ||
| muxer.start() | ||
| copyTrack(videoExtractor, muxer, destinationVideoTrack) | ||
| copyTrack(audioExtractor, muxer, destinationAudioTrack) | ||
| muxer.stop() | ||
| } finally { | ||
| audioExtractor.release() | ||
| videoExtractor.release() | ||
| try { | ||
| muxer?.release() | ||
| } catch (_: Exception) { | ||
| // Best-effort muxer cleanup after a packaging failure. | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun copyTrack( | ||
| extractor: MediaExtractor, | ||
| muxer: MediaMuxer, | ||
| destinationTrack: Int, | ||
| ) { | ||
| val buffer = ByteBuffer.allocate(copyBufferSize) | ||
| val bufferInfo = MediaCodec.BufferInfo() | ||
| while (true) { | ||
| buffer.clear() | ||
| val sampleSize = extractor.readSampleData(buffer, 0) | ||
| if (sampleSize < 0) return | ||
| bufferInfo.offset = 0 | ||
| bufferInfo.size = sampleSize | ||
| bufferInfo.presentationTimeUs = extractor.sampleTime | ||
| bufferInfo.flags = extractor.sampleFlags | ||
| muxer.writeSampleData(destinationTrack, buffer, bufferInfo) | ||
| extractor.advance() | ||
| } | ||
| } | ||
| } | ||
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
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.