-
Notifications
You must be signed in to change notification settings - Fork 94
/
RecorderThread.kt
367 lines (324 loc) · 14.4 KB
/
RecorderThread.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
package com.chiller3.bcr
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaCodec
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.telecom.Call
import android.telecom.PhoneAccount
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.chiller3.bcr.codec.Codec
import com.chiller3.bcr.codec.Container
import java.io.IOException
import java.lang.Integer.min
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatterBuilder
import java.time.format.SignStyle
import java.time.temporal.ChronoField
import android.os.Process as AndroidProcess
/**
* Captures call audio and encodes it into an output file in the user's selected directory or the
* fallback/default directory.
*
* @constructor Create a thread for recording a call. Note that the system only has a single
* [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the recorded
* audio for each call may not be as expected.
* @param context Used for querying shared preferences and accessing files via SAF. A reference is
* kept in the object.
* @param listener Used for sending completion notifications. The listener is called from this
* thread, not the main thread.
* @param call Used only for determining the output filename and is not saved.
*/
class RecorderThread(
private val context: Context,
private val listener: OnRecordingCompletedListener,
call: Call,
): Thread() {
@Volatile private var isCancelled = false
private var captureFailed = false
private val handleUri: Uri = call.details.handle
private val creationTime: Long = call.details.creationTimeMillis
private val direction: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
when (call.details.callDirection) {
Call.Details.DIRECTION_INCOMING -> "in"
Call.Details.DIRECTION_OUTGOING -> "out"
else -> null
}
} else {
null
}
private val displayName: String? = call.details.callerDisplayName
// TODO
private val codec = Codec.default
init {
Log.i(TAG, "[${id}] Created thread for call: $call")
}
private fun getFilename(): String =
buildString {
val instant = Instant.ofEpochMilli(creationTime)
append(FORMATTER.format(ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())))
if (direction != null) {
append('_')
append(direction)
}
if (handleUri.scheme == PhoneAccount.SCHEME_TEL) {
append('_')
append(handleUri.schemeSpecificPart)
}
// AOSP's SAF automatically replaces invalid characters with underscores, but just in
// case an OEM fork breaks that, do the replacement ourselves to prevent directory
// traversal attacks.
val name = displayName?.replace('/', '_')?.trim()
if (!name.isNullOrBlank()) {
append('_')
append(name)
}
}
override fun run() {
var success = false
var resultUri: Uri? = null
try {
Log.i(TAG, "[${id}] Recording thread started")
if (isCancelled) {
Log.i(TAG, "[${id}] Recording cancelled before it began")
} else {
val (uri, pfd) = openOutputFile(getFilename())
resultUri = uri
pfd.use {
recordUntilCancelled(it)
}
success = !captureFailed
}
} catch (e: Exception) {
Log.e(TAG, "[${id}] Error during recording", e)
} finally {
Log.i(TAG, "[${id}] Recording thread completed")
if (success) {
listener.onRecordingCompleted(this, resultUri!!)
} else {
listener.onRecordingFailed(this, resultUri)
}
}
}
/**
* Cancel current recording. This stops capturing audio after approximately 100ms, but the
* thread does not exit until all data encoded so far has been written to the output file.
*
* If called before [start], the thread will not record any audio not create an output file. In
* this scenario, [OnRecordingCompletedListener.onRecordingFailed] will be called with a null
* [Uri].
*/
fun cancel() {
isCancelled = true
}
data class OutputFile(val uri: Uri, val pfd: ParcelFileDescriptor)
/**
* Try to create and open a new output file in the user-chosen directory if possible and fall
* back to the default output directory if not. [name] should not contain a file extension.
*
* @throws IOException if the file could not be created in either directory
*/
private fun openOutputFile(name: String): OutputFile {
val userUri = Preferences.getSavedOutputDir(context)
if (userUri != null) {
try {
// Only returns null on API <21
val userDir = DocumentFile.fromTreeUri(context, userUri)
return openOutputFileInDir(userDir!!, name)
} catch (e: Exception) {
Log.e(TAG, "Failed to open file in user-specified directory: $userUri", e)
}
}
val fallbackDir = DocumentFile.fromFile(Preferences.getDefaultOutputDir(context))
Log.d(TAG, "Using fallback directory: ${fallbackDir.uri}")
return openOutputFileInDir(fallbackDir, name)
}
/**
* Create and open a new output file with name [name] inside [directory]. [name] should not
* contain a file extension. The file extension is automatically determined from [codec].
*
* @throws IOException if file creation or opening fails
*/
private fun openOutputFileInDir(directory: DocumentFile, name: String): OutputFile {
val file = directory.createFile(codec.mimeTypeContainer, name)
?: throw IOException("Failed to create file in ${directory.uri}")
val pfd = context.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file at ${file.uri}")
return OutputFile(file.uri, pfd)
}
/**
* Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
* capture or encoding error occurs.
*
* [pfd] does not get closed by this method.
*/
@SuppressLint("MissingPermission")
private fun recordUntilCancelled(pfd: ParcelFileDescriptor) {
AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_AUDIO)
val audioFormat = AudioFormat.Builder()
.setEncoding(ENCODING)
.setChannelMask(CHANNEL_CONFIG)
.build()
val audioRecord = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_CALL)
.setAudioFormat(audioFormat)
.build()
// Where's my RAII? :(
try {
audioRecord.startRecording()
try {
val mediaFormat = codec.getMediaFormat(audioFormat, audioRecord.sampleRate)
val mediaCodec = codec.getMediaCodec(mediaFormat)
try {
mediaCodec.start()
try {
val container = codec.getContainer(pfd.fileDescriptor)
try {
encodeLoop(audioRecord, mediaCodec, container)
container.stop()
} finally {
container.release()
}
} finally {
mediaCodec.stop()
}
} finally {
mediaCodec.release()
}
} finally {
audioRecord.stop()
}
} finally {
audioRecord.release()
}
}
/**
* Main loop for encoding captured raw audio into an output file.
*
* The loop runs forever until [cancel] is called. At that point, no further data will be read
* from [audioRecord] and the remaining output data from [mediaCodec] will be written to
* [container]. If [audioRecord] fails to capture data, the loop will behave as if [cancel] was
* called (ie. abort, but ensuring that the output file is valid).
*
* The approximate amount of time to cancel reading from the audio source is 100ms. This does
* not include the time required to write out the remaining encoded data to the output file.
*
* @param audioRecord [AudioRecord.startRecording] must have been called
* @param mediaCodec [MediaCodec.start] must have been called
* @param container [Container.start] must *not* have been called. It will be left in a started
* state after this method returns.
*
* @throws MediaCodec.CodecException if the codec encounters an error
*/
private fun encodeLoop(audioRecord: AudioRecord, mediaCodec: MediaCodec, container: Container) {
// This is the most we ever read from audioRecord, even if the codec input buffer is
// larger. This is purely for fast'ish cancellation and not for latency.
val maxSamplesInBytes = audioRecord.sampleRate / 10 * getFrameSize(audioRecord.format)
var inputTimestamp = 0L
var inputComplete = false
val bufferInfo = MediaCodec.BufferInfo()
val frameSize = getFrameSize(audioRecord.format)
var trackIndex = -1
while (true) {
if (!inputComplete) {
val inputBufferId = mediaCodec.dequeueInputBuffer(10000)
if (inputBufferId >= 0) {
val buffer = mediaCodec.getInputBuffer(inputBufferId)!!
val maxRead = min(maxSamplesInBytes, buffer.remaining())
val n = audioRecord.read(buffer, maxRead)
if (n < 0) {
Log.e(TAG, "Error when reading samples from ${audioRecord}: $n")
isCancelled = true
captureFailed = true
} else if (n == 0) {
// This should never be hit because AOSP guarantees that MediaCodec's
// ByteBuffers are direct buffers, but this is not publicly documented
// behavior
Log.e(TAG, "MediaCodec's ByteBuffer was not a direct buffer")
isCancelled = true
} else {
val frames = n / frameSize
inputTimestamp += frames * 1_000_000L / audioRecord.sampleRate
}
if (isCancelled) {
val duration = "%.1f".format(inputTimestamp / 1_000_000.0)
Log.d(TAG, "Input complete after ${duration}s")
inputComplete = true
}
val flags = if (inputComplete) {
MediaCodec.BUFFER_FLAG_END_OF_STREAM
} else {
0
}
// Setting the presentation timestamp will cause `c2.android.flac.encoder`
// software encoder to crash with SIGABRT
mediaCodec.queueInputBuffer(inputBufferId, 0, n, 0, flags)
} else if (inputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
Log.w(TAG, "Unexpected input buffer dequeue error: $inputBufferId")
}
}
val outputBufferId = mediaCodec.dequeueOutputBuffer(bufferInfo, 0)
if (outputBufferId >= 0) {
val buffer = mediaCodec.getOutputBuffer(outputBufferId)!!
container.writeSamples(trackIndex, buffer, bufferInfo)
mediaCodec.releaseOutputBuffer(outputBufferId, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
// Output has been fully written
break
}
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val outputFormat = mediaCodec.outputFormat
Log.d(TAG, "Output format changed to: $outputFormat")
trackIndex = container.addTrack(outputFormat)
container.start()
} else if (outputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
Log.w(TAG, "Unexpected output buffer dequeue error: $outputBufferId")
}
}
}
companion object {
private val TAG = RecorderThread::class.java.simpleName
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
// Eg. 20220429_180249.123-0400
private val FORMATTER = DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
.appendValue(ChronoField.DAY_OF_MONTH, 2)
.appendLiteral('_')
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
.appendOffset("+HHMMss", "+0000")
.toFormatter()
private fun getFrameSize(audioFormat: AudioFormat): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
audioFormat.frameSizeInBytes
} else{
// Hardcoded for Android 9 compatibility only
assert(ENCODING == AudioFormat.ENCODING_PCM_16BIT)
2 * audioFormat.channelCount
}
}
}
interface OnRecordingCompletedListener {
/**
* Called when the recording completes successfully. [uri] is the output file.
*/
fun onRecordingCompleted(thread: RecorderThread, uri: Uri)
/**
* Called when an error occurs during recording. If [uri] is not null, it points to the
* output file containing partially recorded audio. If [uri] is null, then either the output
* file could not be created or the thread was cancelled before it was started.
*/
fun onRecordingFailed(thread: RecorderThread, uri: Uri?)
}
}