Prd/components/dialer-patch/patches/0011-callrecord-dual-leg-capture.patch
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
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.
2026-07-27 18:50:32 +02:00

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();