Skip to content

Commit

Permalink
RecorderThread: Write output format and encoding info to metadata file
Browse files Browse the repository at this point in the history
Issue: #380

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Jul 21, 2023
1 parent f668917 commit 2e1ce9b
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 14 deletions.
75 changes: 72 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ Note that due to Android Storage Access Framework's poor performance, using subd

## Metadata file

If the `Write metadata file` option is enabled, BCR will write a JSON file to the output directory containing all of the details that BCR knows about the call. The file has the same name as the audio file, except with a `.json` extension.
If the `Write metadata file` option is enabled, BCR will write a JSON file to the output directory containing all of the details that BCR knows about the call as well as information about the recorded audio. The file has the same name as the audio file, except with a `.json` extension.

The JSON structure is shown in the following example. The only fields that are guaranteed to exist are the timestamp fields. If the value for a field can't be determined (eg. when a required permission is denied), then it is set to `null`.
The JSON structure is shown in the following example. Note that only `timestamp_unix_ms`, `timestamp`, and `output.format.*` are guaranteed to exist. If the value for a field can't be determined (eg. when an error occurs or a required permission is denied), then it is set to `null`.
```jsonc
{
Expand Down Expand Up @@ -181,7 +181,76 @@ The JSON structure is shown in the following example. The only fields that are g
// [Requires the Contacts permission]
"contact_name": "John Doe"
}
]
],
// Details about the output file.
"output": {
// Details about the output file format.
"format": {
// The audio encoding format.
"type": "OGG/Opus",
// The MIME type of the container format (eg. OGG).
"mime_type_container": "audio/ogg",
// The MIME type of the raw audio stream (eg. Opus).
"mime_type_audio": "audio/opus",
// The type of the parameter value below. Either "bitrate",
// "compression_level", or "none".
"parameter_type": "bitrate",
// The encoder quality/size parameter.
"parameter": 48000,
},
// Details about the recording and encoding process. If the recording
// fails, this is set to null.
"recording": {
// The total number of audio frames that BCR read from the audio
// device. This includes the periods of time when the recording was
// paused or on hold.
// (Number of frames == number of samples * channel count)
"frames_total": 96000,
// The number of audio frames that were actually saved to the output
// file. This excludes the periods of time when the recording was
// paused or on hold.
// (Number of frames == number of samples * channel count)
"frames_encoded": 48000,
// The number of channels in the audio. This is currently always 1
// because no device supports stereo call audio.
"channel_count": 1,
// The number of samples per second of audio.
"sample_rate": 48000,
// The total time in seconds that BCR read from the audio device.
// (Equal to: frames_total / sample_rate / channel_count)
"duration_secs_total": 2.0,
// The time in seconds of audio actually saved to the output file.
// (Equal to: frames_encoded / sample_rate / channel_count)
"duration_secs_encoded": 1.0,
// The size of the recording buffer in frames. This is the maximum
// number of audio frames read from the audio driver before it is
// passed to the audio encoder.
"buffer_frames": 640,
// The number of buffer overruns. This is the number of times that
// the CPU or storage couldn't keep up while encoding the raw audio,
// resulting in skips (loss of audio).
"buffer_overruns": 0,

// Whether the call was ever paused by the user.
"was_ever_paused": false,

// Whether the call was ever placed on hold (call waiting).
"was_ever_holding": false
}
}
}
```
Expand Down
110 changes: 99 additions & 11 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import com.chiller3.bcr.extension.listFilesWithPathsRecursively
import com.chiller3.bcr.extension.phoneNumber
import com.chiller3.bcr.format.Encoder
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.NoParamInfo
import com.chiller3.bcr.format.RangedParamInfo
import com.chiller3.bcr.format.RangedParamType
import com.chiller3.bcr.format.SampleRate
import com.chiller3.bcr.output.CallMetadata
import com.chiller3.bcr.output.CallMetadataCollector
Expand All @@ -29,6 +32,7 @@ import com.chiller3.bcr.output.OutputFilenameGenerator
import com.chiller3.bcr.output.OutputPath
import com.chiller3.bcr.output.Retention
import com.chiller3.bcr.rule.RecordRule
import org.json.JSONObject
import java.lang.Process
import java.nio.ByteBuffer
import java.time.*
Expand Down Expand Up @@ -197,9 +201,11 @@ class RecorderThread(
initialPath.value, format.mimeTypeContainer)
resultUri = outputFile.uri

var recordingInfo: RecordingInfo? = null

try {
dirUtils.openFile(outputFile, true).use {
recordUntilCancelled(it)
recordingInfo = recordUntilCancelled(it)
Os.fsync(it.fileDescriptor)
}
} finally {
Expand All @@ -218,7 +224,9 @@ class RecorderThread(
resultUri = it.uri
}

writeMetadataFile(finalPath.value)?.let { additionalFiles.add(it) }
writeMetadataFile(finalPath.value, recordingInfo)?.let {
additionalFiles.add(it)
}
} else {
Log.i(tag, "Deleting recording: $finalPath")
outputFile.delete()
Expand Down Expand Up @@ -346,7 +354,7 @@ class RecorderThread(
return OutputFile(uri, outputFilenameGenerator.redactor.redact(uri), MIME_LOGCAT)
}

private fun writeMetadataFile(path: List<String>): OutputFile? {
private fun writeMetadataFile(path: List<String>, recordingInfo: RecordingInfo?): OutputFile? {
if (!prefs.writeMetadata) {
Log.i(tag, "Metadata writing is disabled")
return null
Expand All @@ -355,7 +363,42 @@ class RecorderThread(
Log.i(tag, "Writing metadata file")

try {
val metadataJson = callMetadataCollector.callMetadata.toJson(context)
val formatJson = JSONObject().apply {
put("type", format.name)
put("mime_type_container", format.mimeTypeContainer)
put("mime_type_audio", format.mimeTypeAudio)
put("parameter_type", when (val info = format.paramInfo) {
NoParamInfo -> "none"
is RangedParamInfo -> when (info.type) {
RangedParamType.CompressionLevel -> "compression_level"
RangedParamType.Bitrate -> "bitrate"
}
})
put("parameter", (formatParam ?: format.paramInfo.default).toInt())
}
val recordingJson = if (recordingInfo != null) {
JSONObject().apply {
put("frames_total", recordingInfo.framesTotal)
put("frames_encoded", recordingInfo.framesEncoded)
put("channel_count", recordingInfo.channelCount)
put("sample_rate", recordingInfo.sampleRate)
put("duration_secs_total", recordingInfo.durationSecsTotal)
put("duration_secs_encoded", recordingInfo.durationSecsEncoded)
put("buffer_frames", recordingInfo.bufferFrames)
put("buffer_overruns", recordingInfo.bufferOverruns)
put("was_ever_paused", recordingInfo.wasEverPaused)
put("was_ever_holding", recordingInfo.wasEverHolding)
}
} else {
JSONObject.NULL
}
val outputJson = JSONObject().apply {
put("format", formatJson)
put("recording", recordingJson)
}
val metadataJson = callMetadataCollector.callMetadata.toJson(context).apply {
put("output", outputJson)
}
val metadataBytes = metadataJson.toString(4).toByteArray()

val metadataFile = dirUtils.createFileInOutputDir(path, MIME_METADATA)
Expand Down Expand Up @@ -437,7 +480,7 @@ class RecorderThread(
* [pfd] does not get closed by this method.
*/
@SuppressLint("MissingPermission")
private fun recordUntilCancelled(pfd: ParcelFileDescriptor) {
private fun recordUntilCancelled(pfd: ParcelFileDescriptor): RecordingInfo {
AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_URGENT_AUDIO)

val minBufSize = AudioRecord.getMinBufferSize(
Expand Down Expand Up @@ -478,7 +521,7 @@ class RecorderThread(
encoder.start()

try {
encodeLoop(audioRecord, encoder, minBufSize)
return encodeLoop(audioRecord, encoder, minBufSize)
} finally {
encoder.stop()
}
Expand Down Expand Up @@ -514,9 +557,16 @@ class RecorderThread(
*
* @throws Exception if the audio recorder or encoder encounters an error
*/
private fun encodeLoop(audioRecord: AudioRecord, encoder: Encoder, bufSize: Int) {
private fun encodeLoop(
audioRecord: AudioRecord,
encoder: Encoder,
bufSize: Int,
): RecordingInfo {
var numFramesTotal = 0L
var numFramesEncoded = 0L
var bufferOverruns = 0
var wasEverPaused = false
var wasEverHolding = false
val frameSize = audioRecord.format.frameSizeInBytesCompat

// Use a slightly larger buffer to reduce the chance of problems under load
Expand Down Expand Up @@ -552,6 +602,9 @@ class RecorderThread(
if (!isPaused && !isHolding) {
encoder.encode(buffer, false)
numFramesEncoded += n / frameSize
} else {
wasEverPaused = wasEverPaused || isPaused
wasEverHolding = wasEverHolding || isHolding
}

numFramesTotal += n / frameSize
Expand All @@ -563,6 +616,7 @@ class RecorderThread(

val totalElapsed = System.nanoTime() - begin
if (encodeElapsed > bufferNs) {
bufferOverruns += 1
Log.w(tag, "${encoder.javaClass.simpleName} took too long: " +
"timestampTotal=${numFramesTotal.toDouble() / audioRecord.sampleRate}s, " +
"timestampEncode=${numFramesEncoded.toDouble() / audioRecord.sampleRate}s, " +
Expand All @@ -578,10 +632,20 @@ class RecorderThread(
buffer.limit(buffer.position())
encoder.encode(buffer, true)

val durationSecsTotal = numFramesTotal.toDouble() / audioRecord.sampleRate
val durationSecsEncoded = numFramesEncoded.toDouble() / audioRecord.sampleRate
Log.d(tag, "Input complete after ${"%.1f".format(durationSecsTotal)}s " +
"(${"%.1f".format(durationSecsEncoded)}s encoded)")
val recordingInfo = RecordingInfo(
numFramesTotal,
numFramesEncoded,
audioRecord.channelCount,
audioRecord.sampleRate,
bufferFrames,
bufferOverruns,
wasEverPaused,
wasEverHolding,
)

Log.d(tag, "Input complete: $recordingInfo")

return recordingInfo
}

companion object {
Expand All @@ -592,6 +656,30 @@ class RecorderThread(
private const val MIME_METADATA = "application/json"
}

private data class RecordingInfo(
val framesTotal: Long,
val framesEncoded: Long,
val channelCount: Int,
val sampleRate: Int,
val bufferFrames: Long,
val bufferOverruns: Int,
val wasEverPaused: Boolean,
val wasEverHolding: Boolean,
) {
val durationSecsTotal = framesTotal.toDouble() / sampleRate
val durationSecsEncoded = framesEncoded.toDouble() / sampleRate

override fun toString() = buildString {
append("Total: $framesTotal frames (${"%.1f".format(durationSecsTotal)}s)")
append(", Encoded: $framesEncoded frames (${"%.1f".format(durationSecsEncoded)}s)")
append(", Sample rate: $sampleRate")
append(", Buffer frames: $bufferFrames")
append(", Buffer overruns: $bufferOverruns")
append(", Was ever paused: $wasEverPaused")
append(", Was ever holding: $wasEverHolding")
}
}

interface OnRecordingCompletedListener {
/**
* Called when the pause state, keep state, or output filename are changed.
Expand Down

0 comments on commit 2e1ce9b

Please sign in to comment.