/* * Copyright (C) 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 com.android.tv.settings; import android.annotation.IntDef; import android.annotation.MainThread; import android.content.Context; import android.media.AudioAttributes; import android.media.SoundPool; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.provider.Settings; import android.util.Log; import androidx.annotation.NonNull; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.OnLifecycleEvent; import androidx.lifecycle.ProcessLifecycleOwner; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * System sounds player used to play system sounds like select / deselect. * To keep the sound effects in memory only when the app is active, this class observes the * {@link androidx.lifecycle.Lifecycle} of the {@link androidx.lifecycle.ProcessLifecycleOwner} and * loads the sound effect when ON_START occurs and unloads them when ON_STOP occurs. * To achieve a consistent volume among all system sounds the {@link SoundPool} used here is * initialized in the same way as the SoundPool for framework system sounds in SoundEffectsHelper * and the volume attenuation is calculated in the same way as it's done by SoundEffectsHelper. */ public class SystemSoundsPlayer implements LifecycleObserver { public static final int FX_SELECT = 0; public static final int FX_DESELECT = 1; /** @hide */ @IntDef(prefix = "FX_", value = {FX_SELECT, FX_DESELECT}) @Retention(RetentionPolicy.SOURCE) public @interface SystemSoundEffect {} private static final String TAG = SystemSoundsPlayer.class.getSimpleName(); private static final int NUM_SOUNDPOOL_STREAMS = 2; private static final int MSG_PRELOAD_SOUNDS = 0; private static final int MSG_UNLOAD_SOUNDS = 1; private static final int MSG_PLAY_SOUND = 2; private static final int[] FX_RESOURCES = new int[]{ R.raw.Select, R.raw.Deselect }; private final Handler mHandler; private final Context mContext; private final Map mEffectIdToSoundPoolId = new ConcurrentHashMap<>(); private final Set mLoadedSoundPoolIds = new HashSet<>(); private final float mVolumeAttenuation; private SoundPool mSoundPool; public SystemSoundsPlayer(Context context) { mContext = context.getApplicationContext(); float attenuationDb = mContext.getResources().getInteger( com.android.internal.R.integer.config_soundEffectVolumeDb); // This is the same value that is used for framework system sounds as set by // com.android.server.audio.SoundEffectsHelper#onPlaySoundEffect() mVolumeAttenuation = (float) Math.pow(10, attenuationDb / 20); HandlerThread handlerThread = new HandlerThread(TAG + ".handler"); handlerThread.start(); mHandler = new SoundPoolHandler(handlerThread.getLooper()); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); } /** * Plays a sound effect * * @param effect The effect id. */ public void playSoundEffect(@SystemSoundEffect int effect) { if (mSoundPool == null || !querySoundEffectsEnabled()) { return; } switch (effect) { case FX_SELECT: case FX_DESELECT: // any other "case X:" in the future int soundPoolSoundId = getSoundPoolIdForEffect(effect); if (soundPoolSoundId >= 0) { mHandler.sendMessage(mHandler.obtainMessage(MSG_PLAY_SOUND, soundPoolSoundId, 0, mSoundPool)); } else { Log.w(TAG, "playSoundEffect() called but SoundPool is not ready"); } break; default: Log.w(TAG, "Invalid sound id: " + effect); } } @OnLifecycleEvent(Lifecycle.Event.ON_START) private void prepareSoundPool() { if (mSoundPool == null) { mSoundPool = new SoundPool.Builder() .setMaxStreams(NUM_SOUNDPOOL_STREAMS) .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()) .build(); mSoundPool.setOnLoadCompleteListener(new SoundPoolLoadCompleteListener()); mHandler.sendMessage(mHandler.obtainMessage(MSG_PRELOAD_SOUNDS, mSoundPool)); } else { throw new IllegalStateException("prepareSoundPool() was called but SoundPool not null"); } } @OnLifecycleEvent(Lifecycle.Event.ON_STOP) private void releaseSoundPool() { if (mSoundPool == null) { throw new IllegalStateException("releaseSoundPool() was called but SoundPool is null"); } mHandler.sendMessage(mHandler.obtainMessage(MSG_UNLOAD_SOUNDS, mSoundPool)); mSoundPool.setOnLoadCompleteListener(null); mSoundPool = null; mLoadedSoundPoolIds.clear(); } /** * Settings has an in memory cache, so this is fast. */ private boolean querySoundEffectsEnabled() { return Settings.System.getIntForUser(mContext.getContentResolver(), Settings.System.SOUND_EFFECTS_ENABLED, 0, mContext.getUserId()) != 0; } /** * @param effect Any of the defined effect ids. * @return Returns the SoundPool sound id if the sound has been loaded, -1 otherwise. */ private int getSoundPoolIdForEffect(@SystemSoundEffect int effect) { Integer soundPoolSoundId = mEffectIdToSoundPoolId.getOrDefault(effect, -1); if (mLoadedSoundPoolIds.contains(soundPoolSoundId)) { return soundPoolSoundId; } else { return -1; } } private class SoundPoolHandler extends Handler { SoundPoolHandler(@NonNull Looper looper) { super(looper); } @Override public void handleMessage(@NonNull Message msg) { SoundPool soundPool = (SoundPool) msg.obj; switch (msg.what) { case MSG_PRELOAD_SOUNDS: for (int effectId = 0; effectId < FX_RESOURCES.length; effectId++) { int soundPoolSoundId = soundPool.load(mContext, FX_RESOURCES[effectId], /* priority= */ 1); mEffectIdToSoundPoolId.put(effectId, soundPoolSoundId); } break; case MSG_UNLOAD_SOUNDS: mEffectIdToSoundPoolId.clear(); soundPool.release(); break; case MSG_PLAY_SOUND: int soundId = msg.arg1; soundPool.play(soundId, mVolumeAttenuation, mVolumeAttenuation, /* priority= */ 0, /* loop= */0, /* rate= */ 1.0f); break; } } } private class SoundPoolLoadCompleteListener implements SoundPool.OnLoadCompleteListener { @MainThread @Override public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { if (mSoundPool != soundPool) { // in case the soundPool has already been released we do not care return; } if (status == 0) { // sound loaded successfully mLoadedSoundPoolIds.add(sampleId); } else { // error while loading sound, remove it from map to mark it as unloaded Integer effectId = 0; for (; effectId < mEffectIdToSoundPoolId.size(); effectId++) { if (mEffectIdToSoundPoolId.get(effectId) == sampleId) { break; } } mEffectIdToSoundPoolId.remove(effectId); } int remainingToLoad = mEffectIdToSoundPoolId.size() - mLoadedSoundPoolIds.size(); if (remainingToLoad == 0) { soundPool.setOnLoadCompleteListener(null); } } } }