chromium/chromecast/media/cma/backend/android/java/src/org/chromium/chromecast/cma/backend/android/VolumeMap.java

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chromecast.cma.backend.android;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Build;
import android.util.SparseIntArray;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.chromecast.media.AudioContentType;

/**
 * Implements the java-side of the volume control API that maps between volume levels ([0..100])
 * and dBFS values. It uses an Android API that was made public in SDK version 28.
 */
@JNINamespace("chromecast::media")
public final class VolumeMap {
    private static final String TAG = "VolumeMap";

    private static final int DEVICE_TYPE = AudioDeviceInfo.TYPE_BUILTIN_SPEAKER;

    private static AudioManager sAudioManager;

    // Mapping from Android's stream_type to Cast's AudioContentType (used for callback).
    private static final SparseIntArray ANDROID_TYPE_TO_CAST_TYPE_MAP;
    static {
        var array = new SparseIntArray(4);
        array.append(AudioManager.STREAM_MUSIC, AudioContentType.MEDIA);
        array.append(AudioManager.STREAM_ALARM, AudioContentType.ALARM);
        array.append(AudioManager.STREAM_SYSTEM, AudioContentType.COMMUNICATION);
        array.append(AudioManager.STREAM_VOICE_CALL, AudioContentType.OTHER);
        ANDROID_TYPE_TO_CAST_TYPE_MAP = array;
    }

    private static final SparseIntArray MAX_VOLUME_INDEX;
    static {
        var array = new SparseIntArray(4);
        array.append(AudioManager.STREAM_MUSIC,
                getAudioManager().getStreamMaxVolume(AudioManager.STREAM_MUSIC));
        array.append(AudioManager.STREAM_ALARM,
                getAudioManager().getStreamMaxVolume(AudioManager.STREAM_ALARM));
        array.append(AudioManager.STREAM_SYSTEM,
                getAudioManager().getStreamMaxVolume(AudioManager.STREAM_SYSTEM));
        array.append(AudioManager.STREAM_VOICE_CALL,
                getAudioManager().getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL));
        MAX_VOLUME_INDEX = array;
    }

    private static int getStreamMinVolume(int streamType) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            return getAudioManager().getStreamMinVolume(streamType);
        }
        //  Try to use reflection in case API is hidden.
        try {
            return (int) getAudioManager()
                    .getClass()
                    .getMethod("getStreamMinVolume", int.class)
                    .invoke(sAudioManager, streamType);
        } catch (Exception e) {
            Log.w(TAG, "Unsupported Android SDK version: " + Build.VERSION.SDK_INT, e);
            return 0;
        }
    };

    private static final SparseIntArray MIN_VOLUME_INDEX;
    static {
        var array = new SparseIntArray(4);
        array.append(AudioManager.STREAM_MUSIC, getStreamMinVolume(AudioManager.STREAM_MUSIC));
        array.append(AudioManager.STREAM_ALARM, getStreamMinVolume(AudioManager.STREAM_ALARM));
        array.append(AudioManager.STREAM_SYSTEM, getStreamMinVolume(AudioManager.STREAM_SYSTEM));
        array.append(
                AudioManager.STREAM_VOICE_CALL, getStreamMinVolume(AudioManager.STREAM_VOICE_CALL));
        MIN_VOLUME_INDEX = array;
    }

    private static AudioManager getAudioManager() {
        if (sAudioManager == null) {
            Context context = ContextUtils.getApplicationContext();
            sAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
        }
        return sAudioManager;
    }

    private static int getStreamType(int castType) {
        int i = ANDROID_TYPE_TO_CAST_TYPE_MAP.indexOfValue(castType);
        return ANDROID_TYPE_TO_CAST_TYPE_MAP.keyAt(i);
    }

    // Returns the current volume in dB for the given stream type and volume index.
    private static float getStreamVolumeDB(int streamType, int idx) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            return getAudioManager().getStreamVolumeDb(streamType, idx, DEVICE_TYPE);
        }
        float db = 0;
        if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
                || Build.VERSION.SDK_INT == Build.VERSION_CODES.N) {
            // API is hidden, use reflection.
            try {
                db = (float) getAudioManager()
                             .getClass()
                             .getMethod("getStreamVolumeDb", int.class, int.class, int.class)
                             .invoke(sAudioManager, streamType, idx, DEVICE_TYPE);
            } catch (Exception e) {
                Log.e(TAG, "Can not call AudioManager.getStreamVolumeDb():", e);
            }
        } else {
            Log.e(TAG, "Unsupported Android SDK version:" + Build.VERSION.SDK_INT);
        }
        return db;
    }

    /** Return the max volume index for the given cast type. */
    @CalledByNative
    static int getMaxVolumeIndex(int castType) {
        int streamType = getStreamType(castType);
        return MAX_VOLUME_INDEX.get(streamType);
    }

    /**
     * Logs the dB value at each discrete Android volume index for the given cast type.
     * Note that this is not identical to the volume table, which may contain a different number
     * of points and at different levels.
     */
    static void dumpVolumeTables(int castType) {
        int streamType = getStreamType(castType);
        int maxIndex = MAX_VOLUME_INDEX.get(streamType);
        int minIndex = MIN_VOLUME_INDEX.get(streamType);
        Log.i(TAG,
                "Volume points for stream " + streamType + " (maxIndex=" + maxIndex
                        + " minIndex=" + minIndex + "):");
        for (int idx = minIndex; idx <= maxIndex; idx++) {
            float db = getStreamVolumeDB(streamType, idx);
            float level = (float) idx / (float) maxIndex;
            Log.i(TAG, "    " + idx + "(" + level + ") -> " + db);
        }
    }

    /**
     * Returns the dB value for the given volume level using the volume table for the given type.
     */
    @CalledByNative
    static float volumeToDbFs(int castType, float level) {
        level = Math.min(1.0f, Math.max(0.0f, level));
        int streamType = getStreamType(castType);
        int volumeIndex = Math.round(level * (float) MAX_VOLUME_INDEX.get(streamType));
        volumeIndex = Math.max(volumeIndex, MIN_VOLUME_INDEX.get(streamType));
        return getStreamVolumeDB(streamType, volumeIndex);
    }

    /**
     * Returns the volume level for the given dB value using the volume table for the given type.
     */
    static float dbFsToVolume(int castType, float db) {
        int streamType = getStreamType(castType);
        int maxIndex = MAX_VOLUME_INDEX.get(streamType);
        int minIndex = MIN_VOLUME_INDEX.get(streamType);

        float dbMin = getStreamVolumeDB(streamType, minIndex);
        if (db <= dbMin) return (float) minIndex / (float) maxIndex;
        float dbMax = getStreamVolumeDB(streamType, maxIndex);
        if (db >= dbMax) return 1.0f;

        // There are only a few volume index steps, so simply loop through them
        // and find the interval [dbLeft .. dbRight] that contains db, then
        // interpolate to estimate the volume level to return.
        float dbLeft = dbMin;
        float dbRight = dbMin;
        int idx = minIndex + 1;
        for (; idx <= maxIndex; idx++) {
            dbLeft = dbRight;
            dbRight = getStreamVolumeDB(streamType, idx);
            if (db <= dbRight) {
                break;
            }
        }
        float interpolatedIdx = (db - dbLeft) / (dbRight - dbLeft) + (idx - 1);
        float level = Math.min(1.0f, Math.max(0.0f, interpolatedIdx / (float) maxIndex));
        return level;
    }
}