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 com.android.internal.R.integer.config_soundEffectVolumeDb); 80 // This is the same value that is used for framework system sounds as set by 81 // com.android.server.audio.SoundEffectsHelper#onPlaySoundEffect() 82 mVolumeAttenuation = (float) Math.pow(10, attenuationDb / 20); 83 HandlerThread handlerThread = new HandlerThread(TAG + ".handler"); 84 handlerThread.start(); 85 mHandler = new SoundPoolHandler(handlerThread.getLooper()); 86 ProcessLifecycleOwner.get().getLifecycle().addObserver(this); 87 } 88 89 /** 90 * Plays a sound effect 91 * 92 * @param effect The effect id. 93 */ playSoundEffect(@ystemSoundEffect int effect)94 public void playSoundEffect(@SystemSoundEffect int effect) { 95 if (mSoundPool == null || !querySoundEffectsEnabled()) { 96 return; 97 } 98 switch (effect) { 99 case FX_SELECT: 100 case FX_DESELECT: 101 // any other "case X:" in the future 102 int soundPoolSoundId = getSoundPoolIdForEffect(effect); 103 if (soundPoolSoundId >= 0) { 104 mHandler.sendMessage(mHandler.obtainMessage(MSG_PLAY_SOUND, soundPoolSoundId, 0, 105 mSoundPool)); 106 } else { 107 Log.w(TAG, "playSoundEffect() called but SoundPool is not ready"); 108 } 109 break; 110 default: 111 Log.w(TAG, "Invalid sound id: " + effect); 112 } 113 } 114 115 @OnLifecycleEvent(Lifecycle.Event.ON_START) prepareSoundPool()116 private void prepareSoundPool() { 117 if (mSoundPool == null) { 118 mSoundPool = new SoundPool.Builder() 119 .setMaxStreams(NUM_SOUNDPOOL_STREAMS) 120 .setAudioAttributes(new AudioAttributes.Builder() 121 .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) 122 .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) 123 .build()) 124 .build(); 125 mSoundPool.setOnLoadCompleteListener(new SoundPoolLoadCompleteListener()); 126 mHandler.sendMessage(mHandler.obtainMessage(MSG_PRELOAD_SOUNDS, mSoundPool)); 127 } else { 128 throw new IllegalStateException("prepareSoundPool() was called but SoundPool not null"); 129 } 130 } 131 132 @OnLifecycleEvent(Lifecycle.Event.ON_STOP) releaseSoundPool()133 private void releaseSoundPool() { 134 if (mSoundPool == null) { 135 throw new IllegalStateException("releaseSoundPool() was called but SoundPool is null"); 136 } 137 mHandler.sendMessage(mHandler.obtainMessage(MSG_UNLOAD_SOUNDS, mSoundPool)); 138 mSoundPool.setOnLoadCompleteListener(null); 139 mSoundPool = null; 140 mLoadedSoundPoolIds.clear(); 141 } 142 143 /** 144 * Settings has an in memory cache, so this is fast. 145 */ querySoundEffectsEnabled()146 private boolean querySoundEffectsEnabled() { 147 return Settings.System.getIntForUser(mContext.getContentResolver(), 148 Settings.System.SOUND_EFFECTS_ENABLED, 0, mContext.getUserId()) != 0; 149 } 150 151 /** 152 * @param effect Any of the defined effect ids. 153 * @return Returns the SoundPool sound id if the sound has been loaded, -1 otherwise. 154 */ getSoundPoolIdForEffect(@ystemSoundEffect int effect)155 private int getSoundPoolIdForEffect(@SystemSoundEffect int effect) { 156 Integer soundPoolSoundId = mEffectIdToSoundPoolId.getOrDefault(effect, -1); 157 if (mLoadedSoundPoolIds.contains(soundPoolSoundId)) { 158 return soundPoolSoundId; 159 } else { 160 return -1; 161 } 162 } 163 164 private class SoundPoolHandler extends Handler { SoundPoolHandler(@onNull Looper looper)165 SoundPoolHandler(@NonNull Looper looper) { 166 super(looper); 167 } 168 169 @Override handleMessage(@onNull Message msg)170 public void handleMessage(@NonNull Message msg) { 171 SoundPool soundPool = (SoundPool) msg.obj; 172 switch (msg.what) { 173 case MSG_PRELOAD_SOUNDS: 174 for (int effectId = 0; effectId < FX_RESOURCES.length; effectId++) { 175 int soundPoolSoundId = soundPool.load(mContext, 176 FX_RESOURCES[effectId], /* priority= */ 1); 177 mEffectIdToSoundPoolId.put(effectId, soundPoolSoundId); 178 } 179 break; 180 case MSG_UNLOAD_SOUNDS: 181 mEffectIdToSoundPoolId.clear(); 182 soundPool.release(); 183 break; 184 case MSG_PLAY_SOUND: 185 int soundId = msg.arg1; 186 soundPool.play(soundId, mVolumeAttenuation, mVolumeAttenuation, /* priority= */ 187 0, /* loop= */0, /* rate= */ 1.0f); 188 break; 189 } 190 } 191 } 192 193 private class SoundPoolLoadCompleteListener implements 194 SoundPool.OnLoadCompleteListener { 195 @MainThread 196 @Override onLoadComplete(SoundPool soundPool, int sampleId, int status)197 public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { 198 if (mSoundPool != soundPool) { 199 // in case the soundPool has already been released we do not care 200 return; 201 } 202 if (status == 0) { 203 // sound loaded successfully 204 mLoadedSoundPoolIds.add(sampleId); 205 } else { 206 // error while loading sound, remove it from map to mark it as unloaded 207 Integer effectId = 0; 208 for (; effectId < mEffectIdToSoundPoolId.size(); effectId++) { 209 if (mEffectIdToSoundPoolId.get(effectId) == sampleId) { 210 break; 211 } 212 } 213 mEffectIdToSoundPoolId.remove(effectId); 214 } 215 int remainingToLoad = mEffectIdToSoundPoolId.size() - mLoadedSoundPoolIds.size(); 216 if (remainingToLoad == 0) { 217 soundPool.setOnLoadCompleteListener(null); 218 } 219 } 220 } 221 } 222