/* * 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). * *

Why. {@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. * *

This can fail, and failing is fine. 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. * *

The legs drift. 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. * *

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. * *

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 } } }