Prd/components/dialer-patch/overlay/java/com/android/dialer/helpdesk/DualLegCapture.java
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

204 lines
7.9 KiB
Java

/*
* Helpdesk Dialer patch - record the two call legs as separate channels.
* Part of the com.android.dialer.helpdesk overlay (see components/dialer-patch).
*/
package com.android.dialer.helpdesk;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.util.Log;
import com.android.dialer.helpdesk.util.LegInterleaver;
import java.nio.ByteBuffer;
/**
* Captures {@code VOICE_UPLINK} and {@code VOICE_DOWNLINK} as two mono streams and interleaves them
* into one stereo stream: left = uplink (the operator), right = downlink (the caller).
*
* <p><b>Why.</b> {@code VOICE_CALL} hands back a single pre-mixed stream, so the transcriber has to
* infer who was speaking, and a diariser guessing at two similar voices over a narrowband phone line
* gets it wrong. Recorded as separate channels, speaker attribution stops being a guess: each channel
* is one person by construction. AOSP's own note in WavLPCMRecorder says Google Dialer does exactly
* this and speculates it is for transcription.
*
* <p><b>This can fail, and failing is fine.</b> Whether these sources can be opened - and especially
* whether both can be open at the SAME time - is decided by the device's audio HAL and sepolicy, not by
* this code. {@link #open} returns null when they cannot be, and the caller falls back to the ordinary
* single-source path. Nothing here is load-bearing for recording a call.
*
* <p><b>The legs drift.</b> Two independent AudioRecords do not deliver the same number of frames on
* any given cycle, so samples cannot simply be zipped together as they arrive. Each leg keeps a pending
* buffer and only whole frames present on BOTH sides are emitted; the remainder waits. Without that the
* channels would slide out of sync and the timestamps would stop meaning anything.
*
* <p>Interleaving is done on raw byte pairs rather than decoded shorts, which keeps it independent of
* byte order - the PCM arrives native-endian and leaves in exactly the same encoding, just interleaved.
*
* grep anchors: dual-open, dual-read, dual-drift.
*/
public final class DualLegCapture {
private static final String TAG = "HelpdeskDualLeg";
private static final int BYTES_PER_SAMPLE = 2; // ENCODING_PCM_16BIT, one channel
private static final int FRAME_BYTES = BYTES_PER_SAMPLE * 2; // stereo frame: one sample per leg
/** Cap on how far one leg may run ahead while the other stalls (e.g. a leg torn down on hold). */
private static final int MAX_PENDING_BYTES = 16000 * BYTES_PER_SAMPLE * 2; // ~2 s at 16 kHz
private final AudioRecord up;
private final AudioRecord down;
private final byte[] scratch;
private final byte[] mixed;
// The alignment logic lives in util/LegInterleaver so it can be tested off-device: it is the part
// whose failure would be quietest, producing a playable stereo file with the speakers drifting apart.
private final LegInterleaver mix = new LegInterleaver(MAX_PENDING_BYTES);
private int reportedDrops = 0;
private DualLegCapture(AudioRecord up, AudioRecord down, int scratchBytes) {
this.up = up;
this.down = down;
this.scratch = new byte[scratchBytes];
this.mixed = new byte[scratchBytes * 2]; // stereo is twice the mono bytes
}
/**
* Open both legs, or return null if this device will not allow it. grep anchor: dual-open.
*
* <p>Both are opened before either is judged, because a HAL may permit one alone and refuse the
* second - which is the case that matters and the one a sequential probe cannot see.
*/
public static DualLegCapture open(int sampleRate) {
AudioRecord u = null;
AudioRecord d = null;
try {
int min = AudioRecord.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT);
if (min <= 0) {
Log.w(TAG, "getMinBufferSize returned " + min + " - cannot size the legs");
return null;
}
int buf = Math.max(min, sampleRate); // ~0.5 s of mono headroom, same order as the single path
u = new AudioRecord(MediaRecorder.AudioSource.VOICE_UPLINK, sampleRate,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, buf);
d = new AudioRecord(MediaRecorder.AudioSource.VOICE_DOWNLINK, sampleRate,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, buf);
if (u.getState() != AudioRecord.STATE_INITIALIZED
|| d.getState() != AudioRecord.STATE_INITIALIZED) {
Log.w(TAG, "cannot capture the legs separately (uplink=" + u.getState()
+ " downlink=" + d.getState() + ") - falling back to the mixed source");
releaseQuietly(u);
releaseQuietly(d);
return null;
}
Log.i(TAG, "both call legs open at " + sampleRate + " Hz; recording in stereo");
return new DualLegCapture(u, d, buf);
} catch (Throwable t) {
Log.w(TAG, "dual-leg capture unavailable (" + t.getClass().getSimpleName() + ": "
+ t.getMessage() + ") - falling back to the mixed source");
releaseQuietly(u);
releaseQuietly(d);
return null;
}
}
/** Start both legs. If the second refuses, stop the first so we never record a half call. */
public boolean start() {
try {
up.startRecording();
down.startRecording();
boolean ok = up.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING
&& down.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING;
if (!ok) {
Log.w(TAG, "one leg refused to start (uplink=" + up.getRecordingState()
+ " downlink=" + down.getRecordingState() + ")");
stop();
}
return ok;
} catch (Throwable t) {
Log.w(TAG, "starting the legs failed: " + t);
stop();
return false;
}
}
public boolean isRecording() {
return up.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING
&& down.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING;
}
/**
* Drain both legs and write as many interleaved stereo frames as are available on both.
* grep anchor: dual-read.
*
* @return bytes written, 0 when the legs have not yet produced a common frame, or -1 if both legs
* errored (the caller treats that like a negative single-source read: skip, do not tear down).
*/
public int read(ByteBuffer out) {
int ru = drain(up, true);
int rd = drain(down, false);
if (ru < 0 && rd < 0) {
return -1; // both unhappy - most likely the call is on hold, same as the single-source case
}
int room = Math.min(out.remaining(), mixed.length);
int n = mix.drainInto(mixed, 0, room);
if (n > 0) {
out.put(mixed, 0, n);
}
if (mix.droppedBytes() > reportedDrops + (MAX_PENDING_BYTES / 2)) {
reportedDrops = mix.droppedBytes();
Log.w(TAG, "one leg is stalling; dropped " + reportedDrops + " bytes to keep the channels aligned");
}
return n;
}
/** Read what one leg has and hand it to the interleaver. grep anchor: dual-drift. */
private int drain(AudioRecord rec, boolean isUp) {
int n;
try {
n = rec.read(scratch, 0, scratch.length, AudioRecord.READ_NON_BLOCKING);
} catch (Throwable t) {
return -1;
}
if (n > 0) {
if (isUp) {
mix.offerUp(scratch, n);
} else {
mix.offerDown(scratch, n);
}
}
return n;
}
public void stop() {
stopQuietly(up);
stopQuietly(down);
}
public void release() {
releaseQuietly(up);
releaseQuietly(down);
}
private static void stopQuietly(AudioRecord r) {
if (r == null) {
return;
}
try {
if (r.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
r.stop();
}
} catch (Throwable ignored) {
// stopping a leg must never take the recording down with it
}
}
private static void releaseQuietly(AudioRecord r) {
if (r == null) {
return;
}
try {
r.release();
} catch (Throwable ignored) {
// ditto
}
}
}