CarAudioRecord.java

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.car.app.media;

import static android.Manifest.permission.RECORD_AUDIO;

import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static androidx.car.app.utils.CommonUtils.isAutomotiveOS;
import static androidx.car.app.utils.LogTags.TAG;

import static java.lang.annotation.RetentionPolicy.SOURCE;
import static java.util.Objects.requireNonNull;

import android.util.Log;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresPermission;
import androidx.annotation.RestrictTo;
import androidx.car.app.AppManager;
import androidx.car.app.CarContext;
import androidx.car.app.annotations.RequiresCarApi;

import java.lang.annotation.Retention;
import java.lang.reflect.Constructor;

/**
 * The CarAudioRecord class manages access car microphone audio.
 *
 * <p>This is done by reading the data via calls to {@link #read(byte[], int, int)}.
 *
 * <p>The size of the internal buffer for audio data is defined by
 * {@link #AUDIO_CONTENT_BUFFER_SIZE}.  Data should be read from this in chunks of sizes smaller
 * or equal to this value.
 *
 * <p>The sample rate is defined by {@link #AUDIO_CONTENT_SAMPLING_RATE}.
 *
 * <p>The content mime tipe is defined by {@link #AUDIO_CONTENT_MIME}.
 *
 * <p>Whenever the user dismisses the microphone on the car screen, the next call to
 * {@link #read(byte[], int, int)} will return {@code -1}.  When the read call returns {@code -1},
 * it
 * means the user has dismissed the microphone and the data can be ignored
 *
 * <h4>API Usage Example</h4>
 *
 * <pre>{@code
 * CarAudioRecord car = CarAudioRecord.create(carContext);
 * car.startRecording();
 *
 * byte[] data = new byte[CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE];
 * while(car.read(data, 0, CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE) >= 0) {
 *     // Use data array
 *     // Potentially calling car.stopRecording() if your processing finds end of speech
 * }
 * car.stopRecording();
 * }</pre>
 *
 * <h4>Audio Focus</h4>
 *
 * When recording the car microphone, you should first acquire audio focus, to ensure that any
 * ongoing media is stopped.  If you lose audio focus, you should stop recording.
 *
 * Here is an example of how to acquire audio focus:
 *
 * <pre>{@code
 * CarAudioRecord record = CarAudioRecord.create(carContext);
 * // Take audio focus so that user's media is not recorded
 * AudioAttributes audioAttributes =
 *         new AudioAttributes.Builder()
 *                 .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
 *                 .setUsage(USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
 *                 .build();
 *
 * AudioFocusRequest audioFocusRequest =
 *         new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
 *                 .setAudioAttributes(audioAttributes)
 *                 .setOnAudioFocusChangeListener(state -> {
 *                     if (state == AudioManager.AUDIOFOCUS_LOSS) {
 *                         // Stop recording if audio focus is lost
 *                         record.stopRecording();
 *                     }
 *                 })
 *                 .build();
 *
 * if (mCarContext.getSystemService(AudioManager.class).requestAudioFocus(audioFocusRequest)
 *         != AUDIOFOCUS_REQUEST_GRANTED) {
 *     return;
 * }
 *
 * record.startRecording();
 * }</pre>
 */
@RequiresCarApi(5)
public abstract class CarAudioRecord {
    /** The sampling rate of the audio. */
    public static final int AUDIO_CONTENT_SAMPLING_RATE = 16000;

    /** The default buffer size of audio reads from the microphone. */
    public static final int AUDIO_CONTENT_BUFFER_SIZE = 512;

    /** The mime type for raw audio. The car API samples audio at 16khz. */
    public static final String AUDIO_CONTENT_MIME = "audio/l16";

    private static final int RECORDSTATE_STOPPED = 0;
    private static final int RECORDSTATE_RECORDING = 1;
    private static final int RECORDSTATE_REMOTE_CLOSED = 2;

    @IntDef({
            RECORDSTATE_STOPPED,
            RECORDSTATE_RECORDING,
            RECORDSTATE_REMOTE_CLOSED
    })
    @Retention(SOURCE)
    private @interface RecordState {
    }

    @NonNull
    private final CarContext mCarContext;

    @Nullable
    private OpenMicrophoneResponse mOpenMicrophoneResponse;

    /**
     * Indicates the recording state of the CarAudioRecord instance.
     */
    @RecordState
    private int mRecordingState = RECORDSTATE_STOPPED;

    /**
     * Lock to make sure mRecordingState updates are reflecting the actual state of the object.
     */
    private final Object mRecordingStateLock = new Object();

    /**
     * Creates a {@link CarAudioRecord}.
     *
     * @throws NullPointerException if {@code carContext} is {@code null}
     */
    @RequiresPermission(RECORD_AUDIO)
    @NonNull
    public static CarAudioRecord create(@NonNull CarContext carContext) {
        return createCarAudioRecord(carContext, isAutomotiveOS(requireNonNull(carContext))
                ? "androidx.car.app.media.AutomotiveCarAudioRecord"
                : "androidx.car.app.media.ProjectedCarAudioRecord");
    }

    @NonNull
    private static CarAudioRecord createCarAudioRecord(@NonNull CarContext carContext,
            @NonNull String className) {
        try {
            Class<?> managerClass = Class.forName(className);
            Constructor<?> ctor = managerClass.getConstructor(CarContext.class);
            return (CarAudioRecord) ctor.newInstance(carContext);
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("CarAudioRecord not configured. Did you forget "
                    + "to add a dependency on app-automotive or app-projected artifacts?");
        }
    }

    /** @hide */
    @RestrictTo(LIBRARY)
    protected CarAudioRecord(@NonNull CarContext carContext) {
        this.mCarContext = carContext;
    }

    /**
     * Starts recording the car microphone.
     *
     * <p>Read the microphone input via calling {@link #read(byte[], int, int)}
     *
     * <p>When finished processing microphone input, call {@link #stopRecording()}
     */
    public void startRecording() {
        synchronized (mRecordingStateLock) {
            if (mRecordingState != RECORDSTATE_STOPPED) {
                throw new IllegalStateException("Cannot start recording if it has started and not"
                        + " been stopped");
            }

            mOpenMicrophoneResponse =
                    mCarContext.getCarService(AppManager.class).openMicrophone(
                            new OpenMicrophoneRequest.Builder(() -> {
                                synchronized (mRecordingStateLock) {
                                    mRecordingState = RECORDSTATE_REMOTE_CLOSED;
                                }
                            }).build());
            if (mOpenMicrophoneResponse == null) {
                Log.e(TAG, "Did not get microphone input from host");
                mOpenMicrophoneResponse = new OpenMicrophoneResponse.Builder(() -> {
                }).build();
            }
            startRecordingInternal(mOpenMicrophoneResponse);

            mRecordingState = RECORDSTATE_RECORDING;
        }
    }

    /** Stops recording the car microphone. */
    public void stopRecording() {
        synchronized (mRecordingStateLock) {
            if (mOpenMicrophoneResponse != null) {
                if (mRecordingState != RECORDSTATE_REMOTE_CLOSED) {
                    // Don't tell the host to stop, when it already told the client to stop.
                    mOpenMicrophoneResponse.getCarAudioCallback().onStopRecording();
                }
                mOpenMicrophoneResponse = null;
            }

            stopRecordingInternal();
            mRecordingState = RECORDSTATE_STOPPED;
        }
    }

    /**
     * Reads audio data from the car microphone for recording into a byte array.
     *
     * @param audioData     the array to which the recorded audio data is written
     * @param offsetInBytes index in audioData from which the data is written expressed in bytes
     * @param sizeInBytes   the number of requested bytes
     * @return the number of bytes that were read, or {@code -1} if there isn't any more microphone
     * data
     * to read.  The number of bytes will be a multiple of the frame size in bytes not to exceed
     * {@code sizeInBytes}
     * @throws IllegalStateException if called before calling {@link #startRecording()} or after
     *                               calling {@link #stopRecording()}
     */
    public int read(@NonNull byte[] audioData, int offsetInBytes, int sizeInBytes) {
        synchronized (mRecordingStateLock) {
            switch (mRecordingState) {
                case RECORDSTATE_STOPPED:
                    throw new IllegalStateException(
                            "Called read before calling startRecording or after "
                                    + "calling stopRecording");
                case RECORDSTATE_REMOTE_CLOSED:
                    return -1;
                case RECORDSTATE_RECORDING:
                default:
                    break;
            }
        }

        return readInternal(audioData, offsetInBytes, sizeInBytes);
    }

    /**
     * Performs internal platform specific start recording behavior.
     *
     * @param openMicrophoneResponse the response from the host for opening the microphone
     * @hide
     */
    @RestrictTo(LIBRARY)
    protected abstract void startRecordingInternal(
            @NonNull OpenMicrophoneResponse openMicrophoneResponse);

    /**
     * Performs internal platform specific stop recording behavior.
     *
     * @hide
     */
    @RestrictTo(LIBRARY)
    protected abstract void stopRecordingInternal();

    /**
     * Performs internal platform specific read behavior.
     *
     * @param audioData     the array to which the recorded audio data is written
     * @param offsetInBytes index in audioData from which the data is written expressed in bytes
     * @param sizeInBytes   the number of requested bytes
     * @return the number of bytes that were read, or {@code -1} if there isn't any more
     * microphone data to read.  The number of bytes will be a multiple of the frame size in
     * bytes not to exceed {@code sizeInBytes}
     * @hide
     */
    @RestrictTo(LIBRARY)
    protected abstract int readInternal(@NonNull byte[] audioData, int offsetInBytes,
            int sizeInBytes);
}