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