A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
159 lines
8 KiB
Diff
159 lines
8 KiB
Diff
From: Helpdesk <helpdesk@local>
|
|
Subject: [PATCH] callrecord: record the two call legs as separate channels
|
|
|
|
VOICE_CALL hands back one pre-mixed stream, so the transcriber has to infer who was speaking, and a
|
|
diariser guessing between two similar voices on a narrowband line gets it wrong. Captured as separate
|
|
channels - left uplink (operator), right downlink (caller) - speaker attribution stops being a guess:
|
|
each channel is one person by construction. AOSP's own note in WavLPCMRecorder observes that Google
|
|
Dialer does exactly this and speculates it is for transcription.
|
|
|
|
Opt in with persist.helpdesk.rec_dual_leg=1, ideally alongside rec_format=2 (WAV). Off by default.
|
|
|
|
The interleaving lives in the overlay (helpdesk/DualLegCapture.java); this patch only teaches
|
|
BaseCallRecorder that its audio source may be two AudioRecords rather than one. The source is chosen in
|
|
the constructor because it decides the channel count, which the audio format, the PCM buffer size and
|
|
the WAV header are all derived from. mAudioRecord and mDualLeg are mutually exclusive - exactly one is
|
|
the source for any given recording.
|
|
|
|
Falls back silently and completely. Whether both sources can be opened AT ONCE is decided by the audio
|
|
HAL and sepolicy, and a probe that opens them one at a time cannot answer it; if either refuses,
|
|
DualLegCapture.open returns null and recording proceeds on the mixed source exactly as before. Nothing
|
|
here is load-bearing for capturing a call.
|
|
|
|
Sizing now comes from mAudioFormat rather than mAudioRecord, since the latter does not exist in the
|
|
dual-leg case; the values are identical in the single-source case.
|
|
|
|
Applies on top of patches 0001-0010.
|
|
---
|
|
diff --git a/java/com/android/dialer/callrecord/impl/BaseCallRecorder.java b/java/com/android/dialer/callrecord/impl/BaseCallRecorder.java
|
|
index e0a1de6..ca6257a 100644
|
|
--- a/java/com/android/dialer/callrecord/impl/BaseCallRecorder.java
|
|
+++ b/java/com/android/dialer/callrecord/impl/BaseCallRecorder.java
|
|
@@ -54,7 +54,10 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
|
|
protected final OutputFormat mOutputFormat;
|
|
protected final AudioFormat mAudioFormat;
|
|
- private final AudioRecord mAudioRecord;
|
|
+ private final AudioRecord mAudioRecord; // null when recording the legs separately
|
|
+ // Helpdesk patch: non-null when the two call legs are captured as separate channels. Mutually
|
|
+ // exclusive with mAudioRecord - exactly one of the two is the audio source for a given recording.
|
|
+ private final com.android.dialer.helpdesk.DualLegCapture mDualLeg;
|
|
protected final ContentResolver mContentResolver;
|
|
private ParcelFileDescriptor mWritePfd;
|
|
|
|
@@ -88,23 +91,35 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
mOutputFormat = outputFormat;
|
|
|
|
this.mContentResolver = context.getApplicationContext().getContentResolver();
|
|
+ // Helpdesk patch: decide the audio source BEFORE building the format, because capturing the legs
|
|
+ // separately makes the stream stereo and everything below is sized from the channel count. Opening
|
|
+ // is attempted here rather than at start, so a device that refuses falls back before any state is
|
|
+ // built on the assumption.
|
|
+ mDualLeg = com.android.dialer.helpdesk.HelpdeskConfig.recordingDualLeg()
|
|
+ ? com.android.dialer.helpdesk.DualLegCapture.open(outputFormat.sampleRate)
|
|
+ : null;
|
|
+ final int channelMask = mDualLeg != null
|
|
+ ? AudioFormat.CHANNEL_IN_STEREO : outputFormat.channelMask;
|
|
this.mAudioFormat = new AudioFormat.Builder()
|
|
- .setChannelMask(outputFormat.channelMask)
|
|
+ .setChannelMask(channelMask)
|
|
.setSampleRate(outputFormat.sampleRate)
|
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
|
.build();
|
|
// Google Dialer apparently uses mAudioFormat.getSampleRate() for the AudioRecord buffer size.
|
|
final int bufferSizeInBytes = mAudioFormat.getSampleRate();
|
|
- mAudioRecord = new AudioRecord(audioSource, mAudioFormat.getSampleRate(),
|
|
- mAudioFormat.getChannelMask(), mAudioFormat.getEncoding(), bufferSizeInBytes);
|
|
+ // Only one source exists per recording: the mixed VOICE_CALL stream, or the two legs.
|
|
+ mAudioRecord = mDualLeg != null ? null
|
|
+ : new AudioRecord(audioSource, mAudioFormat.getSampleRate(),
|
|
+ mAudioFormat.getChannelMask(), mAudioFormat.getEncoding(), bufferSizeInBytes);
|
|
mUri = uri;
|
|
|
|
- final int framesInDurationMs = mAudioRecord.getSampleRate() * DURATION_TO_READ_MS / 1000;
|
|
+ // Sized from mAudioFormat rather than mAudioRecord, which does not exist in the dual-leg case.
|
|
+ final int framesInDurationMs = mAudioFormat.getSampleRate() * DURATION_TO_READ_MS / 1000;
|
|
mPcmBufferSize = Math.max(
|
|
mAudioFormat.getFrameSizeInBytes() * framesInDurationMs,
|
|
AudioRecord.getMinBufferSize(
|
|
- mAudioRecord.getSampleRate(), mAudioRecord.getChannelConfiguration(),
|
|
- mAudioRecord.getAudioFormat()));
|
|
+ mAudioFormat.getSampleRate(), mAudioFormat.getChannelMask(),
|
|
+ mAudioFormat.getEncoding()));
|
|
Log.d(TAG, "mPcmBufferSize " + mPcmBufferSize);
|
|
mAudioBufferPool = new ByteBufferPool(BUFFER_POOL_NUM_BUFFERS, mPcmBufferSize);
|
|
if (com.android.dialer.helpdesk.HelpdeskConfig.recordingProbeSources()) {
|
|
@@ -211,7 +226,15 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
|
|
onRecordingStart(mWritePfd);
|
|
Log.d(TAG, "start recording");
|
|
- mAudioRecord.startRecording();
|
|
+ // Helpdesk patch: start whichever source this recording is using.
|
|
+ if (mDualLeg != null) {
|
|
+ if (!mDualLeg.start()) {
|
|
+ Log.e(TAG, "dual-leg capture failed to start; aborting this recording");
|
|
+ return;
|
|
+ }
|
|
+ } else {
|
|
+ mAudioRecord.startRecording();
|
|
+ }
|
|
} catch (Throwable t) {
|
|
closeWritePfd();
|
|
throw t;
|
|
@@ -271,7 +294,9 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
}
|
|
|
|
private boolean shouldContinue() {
|
|
- return mIsRecording && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING;
|
|
+ return mIsRecording && (mDualLeg != null
|
|
+ ? mDualLeg.isRecording()
|
|
+ : mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING);
|
|
}
|
|
|
|
private void closeQuietly() {
|
|
@@ -323,8 +348,17 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
}
|
|
|
|
// AudioRecord does not change the ByteBuffer position
|
|
- int read = mAudioRecord.read(mBuffer.data, mBuffer.data.capacity(),
|
|
- AudioRecord.READ_NON_BLOCKING);
|
|
+ // Helpdesk patch: in dual-leg mode the read interleaves both legs into this buffer instead. It
|
|
+ // returns 0 until both have produced a common frame, and -1 only when both legs error, which the
|
|
+ // hold-resilient branch below already handles correctly.
|
|
+ int read;
|
|
+ if (mDualLeg != null) {
|
|
+ mBuffer.data.clear();
|
|
+ read = mDualLeg.read(mBuffer.data);
|
|
+ } else {
|
|
+ read = mAudioRecord.read(mBuffer.data, mBuffer.data.capacity(),
|
|
+ AudioRecord.READ_NON_BLOCKING);
|
|
+ }
|
|
if (read > 0) {
|
|
mReadErrorStreak = 0;
|
|
// This will set buffer's limit and reset position
|
|
@@ -358,7 +392,11 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
|
|
private void stopAudioRecordResourcesAndClosePool() {
|
|
try {
|
|
- mAudioRecord.stop();
|
|
+ if (mDualLeg != null) {
|
|
+ mDualLeg.stop();
|
|
+ } else {
|
|
+ mAudioRecord.stop();
|
|
+ }
|
|
} catch (IllegalStateException ignored) {
|
|
}
|
|
// After the pool is closed, the consumer thread will continue until no more buffers left to
|
|
@@ -487,7 +525,11 @@ public abstract class BaseCallRecorder implements Closeable {
|
|
mIsRecording = false;
|
|
onClose();
|
|
mAudioBufferPool.close();
|
|
- mAudioRecord.release();
|
|
+ if (mDualLeg != null) {
|
|
+ mDualLeg.release();
|
|
+ } else {
|
|
+ mAudioRecord.release();
|
|
+ }
|
|
mAudioBufferProducerExecutor.shutdownNow();
|
|
mAudioBufferConsumerExecutor.shutdownNow();
|
|
closeWritePfd();
|