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.
204 lines
7.9 KiB
Java
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
|
|
}
|
|
}
|
|
}
|