1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tv.settings; 18 19 import android.annotation.IntDef; 20 import android.annotation.MainThread; 21 import android.content.Context; 22 import android.media.AudioAttributes; 23 import android.media.SoundPool; 24 import android.os.Handler; 25 import android.os.HandlerThread; 26 import android.os.Looper; 27 import android.os.Message; 28 import android.provider.Settings; 29 import android.util.Log; 30 31 import androidx.annotation.NonNull; 32 import androidx.lifecycle.Lifecycle; 33 import androidx.lifecycle.LifecycleObserver; 34 import androidx.lifecycle.OnLifecycleEvent; 35 import androidx.lifecycle.ProcessLifecycleOwner; 36 37 import java.lang.annotation.Retention; 38 import java.lang.annotation.RetentionPolicy; 39 import java.util.HashSet; 40 import java.util.Map; 41 import java.util.Set; 42 import java.util.concurrent.ConcurrentHashMap; 43 44 /** 45 * System sounds player used to play system sounds like select / deselect. 46 * To keep the sound effects in memory only when the app is active, this class observes the 47 * {@link androidx.lifecycle.Lifecycle} of the {@link androidx.lifecycle.ProcessLifecycleOwner} and 48 * loads the sound effect when ON_START occurs and unloads them when ON_STOP occurs. 49 * To achieve a consistent volume among all system sounds the {@link SoundPool} used here is 50 * initialized in the same way as the SoundPool for framework system sounds in SoundEffectsHelper 51 * and the volume attenuation is calculated in the same way as it's done by SoundEffectsHelper. 52 */ 53 public class SystemSoundsPlayer implements LifecycleObserver { 54 public static final int FX_SELECT = 0; 55 public static final int FX_DESELECT = 1; 56 /** @hide */ 57 @IntDef(prefix = "FX_", value = {FX_SELECT, FX_DESELECT}) 58 @Retention(RetentionPolicy.SOURCE) 59 public @interface SystemSoundEffect {} 60 private static final String TAG = SystemSoundsPlayer.class.getSimpleName(); 61 private static final int NUM_SOUNDPOOL_STREAMS = 2; 62 private static final int MSG_PRELOAD_SOUNDS = 0; 63 private static final int MSG_UNLOAD_SOUNDS = 1; 64 private static final int MSG_PLAY_SOUND = 2; 65 private static final int[] FX_RESOURCES = new int[]{ 66 R.raw.Select, 67 R.raw.Deselect 68 }; 69 private final Handler mHandler; 70 private final Context mContext; 71 private final Map<Integer, Integer> mEffectIdToSoundPoolId = new ConcurrentHashMap<>(); 72 private final Set<Integer> mLoadedSoundPoolIds = new HashSet<>(); 73 private final float mVolumeAttenuation; 74 private SoundPool mSoundPool; 75 SystemSoundsPlayer(Context context)76 public SystemSoundsPlayer(Context context) { 77 mContext = context.getApplicationContext(); 78 float attenuationDb = mContext.getResources().getInteger( 79 mContext.getResources().getIdentifier("config_soundEffectVolumeDb", 80 "integer", "android")); 81 // This is the same value that is used for framework system sounds as set by 82 // com.android.server.audio.SoundEffectsHelper#onPlaySoundEffect() 83 mVolumeAttenuation = (float) Math.pow(10, attenuationDb / 20); 84 HandlerThread handlerThread = new HandlerThread(TAG + ".handler"); 85 handlerThread.start(); 86 mHandler = new SoundPoolHandler(handlerThread.getLooper()); 87 ProcessLifecycleOwner.get().getLifecycle().addObserver(this); 88 } 89 90 /** 91 * Plays a sound effect 92 * 93 * @param effect The effect id. 94 */ playSoundEffect(@ystemSoundEffect int effect)95 public void playSoundEffect(@SystemSoundEffect int effect) { 96 if (mSoundPool == null || !querySoundEffectsEnabled()) { 97 return; 98 } 99 switch (effect) { 100 case FX_SELECT: 101 case FX_DESELECT: 102 // any other "case X:" in the future 103 int soundPoolSoundId = getSoundPoolIdForEffect(effect); 104 if (soundPoolSoundId >= 0) { 105 mHandler.sendMessage(mHandler.obtainMessage(MSG_PLAY_SOUND, soundPoolSoundId, 0, 106 mSoundPool)); 107 } else { 108 Log.w(TAG, "playSoundEffect() called but SoundPool is not ready"); 109 } 110 break; 111 default: 112 Log.w(TAG, "Invalid sound id: " + effect); 113 } 114 } 115 116 @OnLifecycleEvent(Lifecycle.Event.ON_START) prepareSoundPool()117 private void prepareSoundPool() { 118 if (mSoundPool == null) { 119 mSoundPool = new SoundPool.Builder() 120 .setMaxStreams(NUM_SOUNDPOOL_STREAMS) 121 .setAudioAttributes(new AudioAttributes.Builder() 122 .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) 123 .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) 124 .build()) 125 .build(); 126 mSoundPool.setOnLoadCompleteListener(new SoundPoolLoadCompleteListener()); 127 mHandler.sendMessage(mHandler.obtainMessage(MSG_PRELOAD_SOUNDS, mSoundPool)); 128 } else { 129 throw new IllegalStateException("prepareSoundPool() was called but SoundPool not null"); 130 } 131 } 132 133 @OnLifecycleEvent(Lifecycle.Event.ON_STOP) releaseSoundPool()134 private void releaseSoundPool() { 135 if (mSoundPool == null) { 136 throw new IllegalStateException("releaseSoundPool() was called but SoundPool is null"); 137 } 138 mHandler.sendMessage(mHandler.obtainMessage(MSG_UNLOAD_SOUNDS, mSoundPool)); 139 mSoundPool.setOnLoadCompleteListener(null); 140 mSoundPool = null; 141 mLoadedSoundPoolIds.clear(); 142 } 143 144 /** 145 * Settings has an in memory cache, so this is fast. 146 */ querySoundEffectsEnabled()147 private boolean querySoundEffectsEnabled() { 148 return Settings.System.getIntForUser(mContext.getContentResolver(), 149 Settings.System.SOUND_EFFECTS_ENABLED, 0, mContext.getUserId()) != 0; 150 } 151 152 /** 153 * @param effect Any of the defined effect ids. 154 * @return Returns the SoundPool sound id if the sound has been loaded, -1 otherwise. 155 */ getSoundPoolIdForEffect(@ystemSoundEffect int effect)156 private int getSoundPoolIdForEffect(@SystemSoundEffect int effect) { 157 Integer soundPoolSoundId = mEffectIdToSoundPoolId.getOrDefault(effect, -1); 158 if (mLoadedSoundPoolIds.contains(soundPoolSoundId)) { 159 return soundPoolSoundId; 160 } else { 161 return -1; 162 } 163 } 164 165 private class SoundPoolHandler extends Handler { SoundPoolHandler(@onNull Looper looper)166 SoundPoolHandler(@NonNull Looper looper) { 167 super(looper); 168 } 169 170 @Override handleMessage(@onNull Message msg)171 public void handleMessage(@NonNull Message msg) { 172 SoundPool soundPool = (SoundPool) msg.obj; 173 switch (msg.what) { 174 case MSG_PRELOAD_SOUNDS: 175 for (int effectId = 0; effectId < FX_RESOURCES.length; effectId++) { 176 int soundPoolSoundId = soundPool.load(mContext, 177 FX_RESOURCES[effectId], /* priority= */ 1); 178 mEffectIdToSoundPoolId.put(effectId, soundPoolSoundId); 179 } 180 break; 181 case MSG_UNLOAD_SOUNDS: 182 mEffectIdToSoundPoolId.clear(); 183 soundPool.release(); 184 break; 185 case MSG_PLAY_SOUND: 186 int soundId = msg.arg1; 187 soundPool.play(soundId, mVolumeAttenuation, mVolumeAttenuation, /* priority= */ 188 0, /* loop= */0, /* rate= */ 1.0f); 189 break; 190 } 191 } 192 } 193 194 private class SoundPoolLoadCompleteListener implements 195 SoundPool.OnLoadCompleteListener { 196 @MainThread 197 @Override onLoadComplete(SoundPool soundPool, int sampleId, int status)198 public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { 199 if (mSoundPool != soundPool) { 200 // in case the soundPool has already been released we do not care 201 return; 202 } 203 if (status == 0) { 204 // sound loaded successfully 205 mLoadedSoundPoolIds.add(sampleId); 206 } else { 207 // error while loading sound, remove it from map to mark it as unloaded 208 Integer effectId = 0; 209 for (; effectId < mEffectIdToSoundPoolId.size(); effectId++) { 210 if (mEffectIdToSoundPoolId.get(effectId) == sampleId) { 211 break; 212 } 213 } 214 mEffectIdToSoundPoolId.remove(effectId); 215 } 216 int remainingToLoad = mEffectIdToSoundPoolId.size() - mLoadedSoundPoolIds.size(); 217 if (remainingToLoad == 0) { 218 soundPool.setOnLoadCompleteListener(null); 219 } 220 } 221 } 222 } 223