1 /* 2 * Copyright (C) 2019 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.server.audio; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.content.res.XmlResourceParser; 22 import android.media.AudioAttributes; 23 import android.media.AudioManager; 24 import android.media.AudioSystem; 25 import android.media.MediaPlayer; 26 import android.media.MediaPlayer.OnCompletionListener; 27 import android.media.MediaPlayer.OnErrorListener; 28 import android.media.SoundPool; 29 import android.os.Environment; 30 import android.os.Handler; 31 import android.os.Looper; 32 import android.os.Message; 33 import android.util.Log; 34 import android.util.PrintWriterPrinter; 35 36 import com.android.internal.util.XmlUtils; 37 38 import org.xmlpull.v1.XmlPullParserException; 39 40 import java.io.File; 41 import java.io.IOException; 42 import java.io.PrintWriter; 43 import java.lang.reflect.Field; 44 import java.util.ArrayList; 45 import java.util.List; 46 47 /** 48 * A helper class for managing sound effects loading / unloading 49 * used by AudioService. As its methods are called on the message handler thread 50 * of AudioService, the actual work is offloaded to a dedicated thread. 51 * This helps keeping AudioService responsive. 52 * @hide 53 */ 54 class SoundEffectsHelper { 55 private static final String TAG = "AS.SfxHelper"; 56 57 private static final int NUM_SOUNDPOOL_CHANNELS = 4; 58 59 /* Sound effect file names */ 60 private static final String SOUND_EFFECTS_PATH = "/media/audio/ui/"; 61 62 private static final int EFFECT_NOT_IN_SOUND_POOL = 0; // SoundPool sample IDs > 0 63 64 private static final int MSG_LOAD_EFFECTS = 0; 65 private static final int MSG_UNLOAD_EFFECTS = 1; 66 private static final int MSG_PLAY_EFFECT = 2; 67 private static final int MSG_LOAD_EFFECTS_TIMEOUT = 3; 68 69 interface OnEffectsLoadCompleteHandler { run(boolean success)70 void run(boolean success); 71 } 72 73 private final AudioEventLogger mSfxLogger = new AudioEventLogger( 74 AudioManager.NUM_SOUND_EFFECTS + 10, "Sound Effects Loading"); 75 76 private final Context mContext; 77 // default attenuation applied to sound played with playSoundEffect() 78 private final int mSfxAttenuationDb; 79 80 // thread for doing all work 81 private SfxWorker mSfxWorker; 82 // thread's message handler 83 private SfxHandler mSfxHandler; 84 85 private static final class Resource { 86 final String mFileName; 87 int mSampleId; 88 boolean mLoaded; // for effects in SoundPool Resource(String fileName)89 Resource(String fileName) { 90 mFileName = fileName; 91 mSampleId = EFFECT_NOT_IN_SOUND_POOL; 92 } 93 } 94 // All the fields below are accessed by the worker thread exclusively 95 private final List<Resource> mResources = new ArrayList<Resource>(); 96 private final int[] mEffects = new int[AudioManager.NUM_SOUND_EFFECTS]; // indexes in mResources 97 private SoundPool mSoundPool; 98 private SoundPoolLoader mSoundPoolLoader; 99 SoundEffectsHelper(Context context)100 SoundEffectsHelper(Context context) { 101 mContext = context; 102 mSfxAttenuationDb = mContext.getResources().getInteger( 103 com.android.internal.R.integer.config_soundEffectVolumeDb); 104 startWorker(); 105 } 106 loadSoundEffects(OnEffectsLoadCompleteHandler onComplete)107 /*package*/ void loadSoundEffects(OnEffectsLoadCompleteHandler onComplete) { 108 sendMsg(MSG_LOAD_EFFECTS, 0, 0, onComplete, 0); 109 } 110 111 /** 112 * Unloads samples from the sound pool. 113 * This method can be called to free some memory when 114 * sound effects are disabled. 115 */ unloadSoundEffects()116 /*package*/ void unloadSoundEffects() { 117 sendMsg(MSG_UNLOAD_EFFECTS, 0, 0, null, 0); 118 } 119 playSoundEffect(int effect, int volume)120 /*package*/ void playSoundEffect(int effect, int volume) { 121 sendMsg(MSG_PLAY_EFFECT, effect, volume, null, 0); 122 } 123 dump(PrintWriter pw, String prefix)124 /*package*/ void dump(PrintWriter pw, String prefix) { 125 if (mSfxHandler != null) { 126 pw.println(prefix + "Message handler (watch for unhandled messages):"); 127 mSfxHandler.dump(new PrintWriterPrinter(pw), " "); 128 } else { 129 pw.println(prefix + "Message handler is null"); 130 } 131 pw.println(prefix + "Default attenuation (dB): " + mSfxAttenuationDb); 132 mSfxLogger.dump(pw); 133 } 134 startWorker()135 private void startWorker() { 136 mSfxWorker = new SfxWorker(); 137 mSfxWorker.start(); 138 synchronized (this) { 139 while (mSfxHandler == null) { 140 try { 141 wait(); 142 } catch (InterruptedException e) { 143 Log.w(TAG, "Interrupted while waiting " + mSfxWorker.getName() + " to start"); 144 } 145 } 146 } 147 } 148 sendMsg(int msg, int arg1, int arg2, Object obj, int delayMs)149 private void sendMsg(int msg, int arg1, int arg2, Object obj, int delayMs) { 150 mSfxHandler.sendMessageDelayed(mSfxHandler.obtainMessage(msg, arg1, arg2, obj), delayMs); 151 } 152 logEvent(String msg)153 private void logEvent(String msg) { 154 mSfxLogger.log(new AudioEventLogger.StringEvent(msg)); 155 } 156 157 // All the methods below run on the worker thread onLoadSoundEffects(OnEffectsLoadCompleteHandler onComplete)158 private void onLoadSoundEffects(OnEffectsLoadCompleteHandler onComplete) { 159 if (mSoundPoolLoader != null) { 160 // Loading is ongoing. 161 mSoundPoolLoader.addHandler(onComplete); 162 return; 163 } 164 if (mSoundPool != null) { 165 if (onComplete != null) { 166 onComplete.run(true /*success*/); 167 } 168 return; 169 } 170 171 logEvent("effects loading started"); 172 mSoundPool = new SoundPool.Builder() 173 .setMaxStreams(NUM_SOUNDPOOL_CHANNELS) 174 .setAudioAttributes(new AudioAttributes.Builder() 175 .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) 176 .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) 177 .build()) 178 .build(); 179 loadTouchSoundAssets(); 180 181 mSoundPoolLoader = new SoundPoolLoader(); 182 mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() { 183 @Override 184 public void run(boolean success) { 185 mSoundPoolLoader = null; 186 if (!success) { 187 Log.w(TAG, "onLoadSoundEffects(), Error while loading samples"); 188 onUnloadSoundEffects(); 189 } 190 } 191 }); 192 mSoundPoolLoader.addHandler(onComplete); 193 194 int resourcesToLoad = 0; 195 for (Resource res : mResources) { 196 String filePath = getResourceFilePath(res); 197 int sampleId = mSoundPool.load(filePath, 0); 198 if (sampleId > 0) { 199 res.mSampleId = sampleId; 200 res.mLoaded = false; 201 resourcesToLoad++; 202 } else { 203 logEvent("effect " + filePath + " rejected by SoundPool"); 204 Log.w(TAG, "SoundPool could not load file: " + filePath); 205 } 206 } 207 208 if (resourcesToLoad > 0) { 209 sendMsg(MSG_LOAD_EFFECTS_TIMEOUT, 0, 0, null, SOUND_EFFECTS_LOAD_TIMEOUT_MS); 210 } else { 211 logEvent("effects loading completed, no effects to load"); 212 mSoundPoolLoader.onComplete(true /*success*/); 213 } 214 } 215 onUnloadSoundEffects()216 void onUnloadSoundEffects() { 217 if (mSoundPool == null) { 218 return; 219 } 220 if (mSoundPoolLoader != null) { 221 mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() { 222 @Override 223 public void run(boolean success) { 224 onUnloadSoundEffects(); 225 } 226 }); 227 } 228 229 logEvent("effects unloading started"); 230 for (Resource res : mResources) { 231 if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL) { 232 mSoundPool.unload(res.mSampleId); 233 } 234 } 235 mSoundPool.release(); 236 mSoundPool = null; 237 logEvent("effects unloading completed"); 238 } 239 onPlaySoundEffect(int effect, int volume)240 void onPlaySoundEffect(int effect, int volume) { 241 float volFloat; 242 // use default if volume is not specified by caller 243 if (volume < 0) { 244 volFloat = (float) Math.pow(10, (float) mSfxAttenuationDb / 20); 245 } else { 246 volFloat = volume / 1000.0f; 247 } 248 249 Resource res = mResources.get(mEffects[effect]); 250 if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && res.mLoaded) { 251 mSoundPool.play(res.mSampleId, volFloat, volFloat, 0, 0, 1.0f); 252 } else { 253 MediaPlayer mediaPlayer = new MediaPlayer(); 254 try { 255 String filePath = getResourceFilePath(res); 256 mediaPlayer.setDataSource(filePath); 257 mediaPlayer.setAudioStreamType(AudioSystem.STREAM_SYSTEM); 258 mediaPlayer.prepare(); 259 mediaPlayer.setVolume(volFloat); 260 mediaPlayer.setOnCompletionListener(new OnCompletionListener() { 261 public void onCompletion(MediaPlayer mp) { 262 cleanupPlayer(mp); 263 } 264 }); 265 mediaPlayer.setOnErrorListener(new OnErrorListener() { 266 public boolean onError(MediaPlayer mp, int what, int extra) { 267 cleanupPlayer(mp); 268 return true; 269 } 270 }); 271 mediaPlayer.start(); 272 } catch (IOException ex) { 273 Log.w(TAG, "MediaPlayer IOException: " + ex); 274 } catch (IllegalArgumentException ex) { 275 Log.w(TAG, "MediaPlayer IllegalArgumentException: " + ex); 276 } catch (IllegalStateException ex) { 277 Log.w(TAG, "MediaPlayer IllegalStateException: " + ex); 278 } 279 } 280 } 281 cleanupPlayer(MediaPlayer mp)282 private static void cleanupPlayer(MediaPlayer mp) { 283 if (mp != null) { 284 try { 285 mp.stop(); 286 mp.release(); 287 } catch (IllegalStateException ex) { 288 Log.w(TAG, "MediaPlayer IllegalStateException: " + ex); 289 } 290 } 291 } 292 293 private static final String TAG_AUDIO_ASSETS = "audio_assets"; 294 private static final String ATTR_VERSION = "version"; 295 private static final String TAG_GROUP = "group"; 296 private static final String ATTR_GROUP_NAME = "name"; 297 private static final String TAG_ASSET = "asset"; 298 private static final String ATTR_ASSET_ID = "id"; 299 private static final String ATTR_ASSET_FILE = "file"; 300 301 private static final String ASSET_FILE_VERSION = "1.0"; 302 private static final String GROUP_TOUCH_SOUNDS = "touch_sounds"; 303 304 private static final int SOUND_EFFECTS_LOAD_TIMEOUT_MS = 15000; 305 getResourceFilePath(Resource res)306 private String getResourceFilePath(Resource res) { 307 String filePath = Environment.getProductDirectory() + SOUND_EFFECTS_PATH + res.mFileName; 308 if (!new File(filePath).isFile()) { 309 filePath = Environment.getRootDirectory() + SOUND_EFFECTS_PATH + res.mFileName; 310 } 311 return filePath; 312 } 313 loadTouchSoundAssetDefaults()314 private void loadTouchSoundAssetDefaults() { 315 int defaultResourceIdx = mResources.size(); 316 mResources.add(new Resource("Effect_Tick.ogg")); 317 for (int i = 0; i < mEffects.length; i++) { 318 mEffects[i] = defaultResourceIdx; 319 } 320 } 321 loadTouchSoundAssets()322 private void loadTouchSoundAssets() { 323 XmlResourceParser parser = null; 324 325 // only load assets once. 326 if (!mResources.isEmpty()) { 327 return; 328 } 329 330 loadTouchSoundAssetDefaults(); 331 332 try { 333 parser = mContext.getResources().getXml(com.android.internal.R.xml.audio_assets); 334 335 XmlUtils.beginDocument(parser, TAG_AUDIO_ASSETS); 336 String version = parser.getAttributeValue(null, ATTR_VERSION); 337 boolean inTouchSoundsGroup = false; 338 339 if (ASSET_FILE_VERSION.equals(version)) { 340 while (true) { 341 XmlUtils.nextElement(parser); 342 String element = parser.getName(); 343 if (element == null) { 344 break; 345 } 346 if (element.equals(TAG_GROUP)) { 347 String name = parser.getAttributeValue(null, ATTR_GROUP_NAME); 348 if (GROUP_TOUCH_SOUNDS.equals(name)) { 349 inTouchSoundsGroup = true; 350 break; 351 } 352 } 353 } 354 while (inTouchSoundsGroup) { 355 XmlUtils.nextElement(parser); 356 String element = parser.getName(); 357 if (element == null) { 358 break; 359 } 360 if (element.equals(TAG_ASSET)) { 361 String id = parser.getAttributeValue(null, ATTR_ASSET_ID); 362 String file = parser.getAttributeValue(null, ATTR_ASSET_FILE); 363 int fx; 364 365 try { 366 Field field = AudioManager.class.getField(id); 367 fx = field.getInt(null); 368 } catch (Exception e) { 369 Log.w(TAG, "Invalid touch sound ID: " + id); 370 continue; 371 } 372 373 mEffects[fx] = findOrAddResourceByFileName(file); 374 } else { 375 break; 376 } 377 } 378 } 379 } catch (Resources.NotFoundException e) { 380 Log.w(TAG, "audio assets file not found", e); 381 } catch (XmlPullParserException e) { 382 Log.w(TAG, "XML parser exception reading touch sound assets", e); 383 } catch (IOException e) { 384 Log.w(TAG, "I/O exception reading touch sound assets", e); 385 } finally { 386 if (parser != null) { 387 parser.close(); 388 } 389 } 390 } 391 findOrAddResourceByFileName(String fileName)392 private int findOrAddResourceByFileName(String fileName) { 393 for (int i = 0; i < mResources.size(); i++) { 394 if (mResources.get(i).mFileName.equals(fileName)) { 395 return i; 396 } 397 } 398 int result = mResources.size(); 399 mResources.add(new Resource(fileName)); 400 return result; 401 } 402 findResourceBySampleId(int sampleId)403 private Resource findResourceBySampleId(int sampleId) { 404 for (Resource res : mResources) { 405 if (res.mSampleId == sampleId) { 406 return res; 407 } 408 } 409 return null; 410 } 411 412 private class SfxWorker extends Thread { SfxWorker()413 SfxWorker() { 414 super("AS.SfxWorker"); 415 } 416 417 @Override run()418 public void run() { 419 Looper.prepare(); 420 synchronized (SoundEffectsHelper.this) { 421 mSfxHandler = new SfxHandler(); 422 SoundEffectsHelper.this.notify(); 423 } 424 Looper.loop(); 425 } 426 } 427 428 private class SfxHandler extends Handler { 429 @Override handleMessage(Message msg)430 public void handleMessage(Message msg) { 431 switch (msg.what) { 432 case MSG_LOAD_EFFECTS: 433 onLoadSoundEffects((OnEffectsLoadCompleteHandler) msg.obj); 434 break; 435 case MSG_UNLOAD_EFFECTS: 436 onUnloadSoundEffects(); 437 break; 438 case MSG_PLAY_EFFECT: 439 onLoadSoundEffects(new OnEffectsLoadCompleteHandler() { 440 @Override 441 public void run(boolean success) { 442 if (success) { 443 onPlaySoundEffect(msg.arg1 /*effect*/, msg.arg2 /*volume*/); 444 } 445 } 446 }); 447 break; 448 case MSG_LOAD_EFFECTS_TIMEOUT: 449 if (mSoundPoolLoader != null) { 450 mSoundPoolLoader.onTimeout(); 451 } 452 break; 453 } 454 } 455 } 456 457 private class SoundPoolLoader implements 458 android.media.SoundPool.OnLoadCompleteListener { 459 460 private List<OnEffectsLoadCompleteHandler> mLoadCompleteHandlers = 461 new ArrayList<OnEffectsLoadCompleteHandler>(); 462 SoundPoolLoader()463 SoundPoolLoader() { 464 // SoundPool use the current Looper when creating its message handler. 465 // Since SoundPoolLoader is created on the SfxWorker thread, SoundPool's 466 // message handler ends up running on it (it's OK to have multiple 467 // handlers on the same Looper). Thus, onLoadComplete gets executed 468 // on the worker thread. 469 mSoundPool.setOnLoadCompleteListener(this); 470 } 471 addHandler(OnEffectsLoadCompleteHandler handler)472 void addHandler(OnEffectsLoadCompleteHandler handler) { 473 if (handler != null) { 474 mLoadCompleteHandlers.add(handler); 475 } 476 } 477 478 @Override onLoadComplete(SoundPool soundPool, int sampleId, int status)479 public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { 480 if (status == 0) { 481 int remainingToLoad = 0; 482 for (Resource res : mResources) { 483 if (res.mSampleId == sampleId && !res.mLoaded) { 484 logEvent("effect " + res.mFileName + " loaded"); 485 res.mLoaded = true; 486 } 487 if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && !res.mLoaded) { 488 remainingToLoad++; 489 } 490 } 491 if (remainingToLoad == 0) { 492 onComplete(true); 493 } 494 } else { 495 Resource res = findResourceBySampleId(sampleId); 496 String filePath; 497 if (res != null) { 498 filePath = getResourceFilePath(res); 499 } else { 500 filePath = "with unknown sample ID " + sampleId; 501 } 502 logEvent("effect " + filePath + " loading failed, status " + status); 503 Log.w(TAG, "onLoadSoundEffects(), Error " + status + " while loading sample " 504 + filePath); 505 onComplete(false); 506 } 507 } 508 onTimeout()509 void onTimeout() { 510 onComplete(false); 511 } 512 onComplete(boolean success)513 void onComplete(boolean success) { 514 mSoundPool.setOnLoadCompleteListener(null); 515 for (OnEffectsLoadCompleteHandler handler : mLoadCompleteHandlers) { 516 handler.run(success); 517 } 518 logEvent("effects loading " + (success ? "completed" : "failed")); 519 } 520 } 521 } 522