| /* |
| * Copyright (C) 2019 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.server.audio; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.content.res.XmlResourceParser; |
| import android.media.AudioAttributes; |
| import android.media.AudioManager; |
| import android.media.AudioSystem; |
| import android.media.MediaPlayer; |
| import android.media.MediaPlayer.OnCompletionListener; |
| import android.media.MediaPlayer.OnErrorListener; |
| import android.media.SoundPool; |
| import android.os.Environment; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.Message; |
| import android.util.Log; |
| import android.util.PrintWriterPrinter; |
| |
| import com.android.internal.util.XmlUtils; |
| |
| import org.xmlpull.v1.XmlPullParserException; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.PrintWriter; |
| import java.lang.reflect.Field; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * A helper class for managing sound effects loading / unloading |
| * used by AudioService. As its methods are called on the message handler thread |
| * of AudioService, the actual work is offloaded to a dedicated thread. |
| * This helps keeping AudioService responsive. |
| * @hide |
| */ |
| class SoundEffectsHelper { |
| private static final String TAG = "AS.SfxHelper"; |
| |
| private static final int NUM_SOUNDPOOL_CHANNELS = 4; |
| |
| /* Sound effect file names */ |
| private static final String SOUND_EFFECTS_PATH = "/media/audio/ui/"; |
| |
| private static final int EFFECT_NOT_IN_SOUND_POOL = 0; // SoundPool sample IDs > 0 |
| |
| private static final int MSG_LOAD_EFFECTS = 0; |
| private static final int MSG_UNLOAD_EFFECTS = 1; |
| private static final int MSG_PLAY_EFFECT = 2; |
| private static final int MSG_LOAD_EFFECTS_TIMEOUT = 3; |
| |
| interface OnEffectsLoadCompleteHandler { |
| void run(boolean success); |
| } |
| |
| private final AudioEventLogger mSfxLogger = new AudioEventLogger( |
| AudioManager.NUM_SOUND_EFFECTS + 10, "Sound Effects Loading"); |
| |
| private final Context mContext; |
| // default attenuation applied to sound played with playSoundEffect() |
| private final int mSfxAttenuationDb; |
| |
| // thread for doing all work |
| private SfxWorker mSfxWorker; |
| // thread's message handler |
| private SfxHandler mSfxHandler; |
| |
| private static final class Resource { |
| final String mFileName; |
| int mSampleId; |
| boolean mLoaded; // for effects in SoundPool |
| Resource(String fileName) { |
| mFileName = fileName; |
| mSampleId = EFFECT_NOT_IN_SOUND_POOL; |
| } |
| } |
| // All the fields below are accessed by the worker thread exclusively |
| private final List<Resource> mResources = new ArrayList<Resource>(); |
| private final int[] mEffects = new int[AudioManager.NUM_SOUND_EFFECTS]; // indexes in mResources |
| private SoundPool mSoundPool; |
| private SoundPoolLoader mSoundPoolLoader; |
| |
| SoundEffectsHelper(Context context) { |
| mContext = context; |
| mSfxAttenuationDb = mContext.getResources().getInteger( |
| com.android.internal.R.integer.config_soundEffectVolumeDb); |
| startWorker(); |
| } |
| |
| /*package*/ void loadSoundEffects(OnEffectsLoadCompleteHandler onComplete) { |
| sendMsg(MSG_LOAD_EFFECTS, 0, 0, onComplete, 0); |
| } |
| |
| /** |
| * Unloads samples from the sound pool. |
| * This method can be called to free some memory when |
| * sound effects are disabled. |
| */ |
| /*package*/ void unloadSoundEffects() { |
| sendMsg(MSG_UNLOAD_EFFECTS, 0, 0, null, 0); |
| } |
| |
| /*package*/ void playSoundEffect(int effect, int volume) { |
| sendMsg(MSG_PLAY_EFFECT, effect, volume, null, 0); |
| } |
| |
| /*package*/ void dump(PrintWriter pw, String prefix) { |
| if (mSfxHandler != null) { |
| pw.println(prefix + "Message handler (watch for unhandled messages):"); |
| mSfxHandler.dump(new PrintWriterPrinter(pw), " "); |
| } else { |
| pw.println(prefix + "Message handler is null"); |
| } |
| pw.println(prefix + "Default attenuation (dB): " + mSfxAttenuationDb); |
| mSfxLogger.dump(pw); |
| } |
| |
| private void startWorker() { |
| mSfxWorker = new SfxWorker(); |
| mSfxWorker.start(); |
| synchronized (this) { |
| while (mSfxHandler == null) { |
| try { |
| wait(); |
| } catch (InterruptedException e) { |
| Log.w(TAG, "Interrupted while waiting " + mSfxWorker.getName() + " to start"); |
| } |
| } |
| } |
| } |
| |
| private void sendMsg(int msg, int arg1, int arg2, Object obj, int delayMs) { |
| mSfxHandler.sendMessageDelayed(mSfxHandler.obtainMessage(msg, arg1, arg2, obj), delayMs); |
| } |
| |
| private void logEvent(String msg) { |
| mSfxLogger.log(new AudioEventLogger.StringEvent(msg)); |
| } |
| |
| // All the methods below run on the worker thread |
| private void onLoadSoundEffects(OnEffectsLoadCompleteHandler onComplete) { |
| if (mSoundPoolLoader != null) { |
| // Loading is ongoing. |
| mSoundPoolLoader.addHandler(onComplete); |
| return; |
| } |
| if (mSoundPool != null) { |
| if (onComplete != null) { |
| onComplete.run(true /*success*/); |
| } |
| return; |
| } |
| |
| logEvent("effects loading started"); |
| mSoundPool = new SoundPool.Builder() |
| .setMaxStreams(NUM_SOUNDPOOL_CHANNELS) |
| .setAudioAttributes(new AudioAttributes.Builder() |
| .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) |
| .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) |
| .build()) |
| .build(); |
| loadTouchSoundAssets(); |
| |
| mSoundPoolLoader = new SoundPoolLoader(); |
| mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() { |
| @Override |
| public void run(boolean success) { |
| mSoundPoolLoader = null; |
| if (!success) { |
| Log.w(TAG, "onLoadSoundEffects(), Error while loading samples"); |
| onUnloadSoundEffects(); |
| } |
| } |
| }); |
| mSoundPoolLoader.addHandler(onComplete); |
| |
| int resourcesToLoad = 0; |
| for (Resource res : mResources) { |
| String filePath = getResourceFilePath(res); |
| int sampleId = mSoundPool.load(filePath, 0); |
| if (sampleId > 0) { |
| res.mSampleId = sampleId; |
| res.mLoaded = false; |
| resourcesToLoad++; |
| } else { |
| logEvent("effect " + filePath + " rejected by SoundPool"); |
| Log.w(TAG, "SoundPool could not load file: " + filePath); |
| } |
| } |
| |
| if (resourcesToLoad > 0) { |
| sendMsg(MSG_LOAD_EFFECTS_TIMEOUT, 0, 0, null, SOUND_EFFECTS_LOAD_TIMEOUT_MS); |
| } else { |
| logEvent("effects loading completed, no effects to load"); |
| mSoundPoolLoader.onComplete(true /*success*/); |
| } |
| } |
| |
| void onUnloadSoundEffects() { |
| if (mSoundPool == null) { |
| return; |
| } |
| if (mSoundPoolLoader != null) { |
| mSoundPoolLoader.addHandler(new OnEffectsLoadCompleteHandler() { |
| @Override |
| public void run(boolean success) { |
| onUnloadSoundEffects(); |
| } |
| }); |
| } |
| |
| logEvent("effects unloading started"); |
| for (Resource res : mResources) { |
| if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL) { |
| mSoundPool.unload(res.mSampleId); |
| } |
| } |
| mSoundPool.release(); |
| mSoundPool = null; |
| logEvent("effects unloading completed"); |
| } |
| |
| void onPlaySoundEffect(int effect, int volume) { |
| float volFloat; |
| // use default if volume is not specified by caller |
| if (volume < 0) { |
| volFloat = (float) Math.pow(10, (float) mSfxAttenuationDb / 20); |
| } else { |
| volFloat = volume / 1000.0f; |
| } |
| |
| Resource res = mResources.get(mEffects[effect]); |
| if (mSoundPool != null && res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && res.mLoaded) { |
| mSoundPool.play(res.mSampleId, volFloat, volFloat, 0, 0, 1.0f); |
| } else { |
| MediaPlayer mediaPlayer = new MediaPlayer(); |
| try { |
| String filePath = getResourceFilePath(res); |
| mediaPlayer.setDataSource(filePath); |
| mediaPlayer.setAudioStreamType(AudioSystem.STREAM_SYSTEM); |
| mediaPlayer.prepare(); |
| mediaPlayer.setVolume(volFloat); |
| mediaPlayer.setOnCompletionListener(new OnCompletionListener() { |
| public void onCompletion(MediaPlayer mp) { |
| cleanupPlayer(mp); |
| } |
| }); |
| mediaPlayer.setOnErrorListener(new OnErrorListener() { |
| public boolean onError(MediaPlayer mp, int what, int extra) { |
| cleanupPlayer(mp); |
| return true; |
| } |
| }); |
| mediaPlayer.start(); |
| } catch (IOException ex) { |
| Log.w(TAG, "MediaPlayer IOException: " + ex); |
| } catch (IllegalArgumentException ex) { |
| Log.w(TAG, "MediaPlayer IllegalArgumentException: " + ex); |
| } catch (IllegalStateException ex) { |
| Log.w(TAG, "MediaPlayer IllegalStateException: " + ex); |
| } |
| } |
| } |
| |
| private static void cleanupPlayer(MediaPlayer mp) { |
| if (mp != null) { |
| try { |
| mp.stop(); |
| mp.release(); |
| } catch (IllegalStateException ex) { |
| Log.w(TAG, "MediaPlayer IllegalStateException: " + ex); |
| } |
| } |
| } |
| |
| private static final String TAG_AUDIO_ASSETS = "audio_assets"; |
| private static final String ATTR_VERSION = "version"; |
| private static final String TAG_GROUP = "group"; |
| private static final String ATTR_GROUP_NAME = "name"; |
| private static final String TAG_ASSET = "asset"; |
| private static final String ATTR_ASSET_ID = "id"; |
| private static final String ATTR_ASSET_FILE = "file"; |
| |
| private static final String ASSET_FILE_VERSION = "1.0"; |
| private static final String GROUP_TOUCH_SOUNDS = "touch_sounds"; |
| |
| private static final int SOUND_EFFECTS_LOAD_TIMEOUT_MS = 15000; |
| |
| private String getResourceFilePath(Resource res) { |
| String filePath = Environment.getProductDirectory() + SOUND_EFFECTS_PATH + res.mFileName; |
| if (!new File(filePath).isFile()) { |
| filePath = Environment.getRootDirectory() + SOUND_EFFECTS_PATH + res.mFileName; |
| } |
| return filePath; |
| } |
| |
| private void loadTouchSoundAssetDefaults() { |
| int defaultResourceIdx = mResources.size(); |
| mResources.add(new Resource("Effect_Tick.ogg")); |
| for (int i = 0; i < mEffects.length; i++) { |
| mEffects[i] = defaultResourceIdx; |
| } |
| } |
| |
| private void loadTouchSoundAssets() { |
| XmlResourceParser parser = null; |
| |
| // only load assets once. |
| if (!mResources.isEmpty()) { |
| return; |
| } |
| |
| loadTouchSoundAssetDefaults(); |
| |
| try { |
| parser = mContext.getResources().getXml(com.android.internal.R.xml.audio_assets); |
| |
| XmlUtils.beginDocument(parser, TAG_AUDIO_ASSETS); |
| String version = parser.getAttributeValue(null, ATTR_VERSION); |
| boolean inTouchSoundsGroup = false; |
| |
| if (ASSET_FILE_VERSION.equals(version)) { |
| while (true) { |
| XmlUtils.nextElement(parser); |
| String element = parser.getName(); |
| if (element == null) { |
| break; |
| } |
| if (element.equals(TAG_GROUP)) { |
| String name = parser.getAttributeValue(null, ATTR_GROUP_NAME); |
| if (GROUP_TOUCH_SOUNDS.equals(name)) { |
| inTouchSoundsGroup = true; |
| break; |
| } |
| } |
| } |
| while (inTouchSoundsGroup) { |
| XmlUtils.nextElement(parser); |
| String element = parser.getName(); |
| if (element == null) { |
| break; |
| } |
| if (element.equals(TAG_ASSET)) { |
| String id = parser.getAttributeValue(null, ATTR_ASSET_ID); |
| String file = parser.getAttributeValue(null, ATTR_ASSET_FILE); |
| int fx; |
| |
| try { |
| Field field = AudioManager.class.getField(id); |
| fx = field.getInt(null); |
| } catch (Exception e) { |
| Log.w(TAG, "Invalid touch sound ID: " + id); |
| continue; |
| } |
| |
| mEffects[fx] = findOrAddResourceByFileName(file); |
| } else { |
| break; |
| } |
| } |
| } |
| } catch (Resources.NotFoundException e) { |
| Log.w(TAG, "audio assets file not found", e); |
| } catch (XmlPullParserException e) { |
| Log.w(TAG, "XML parser exception reading touch sound assets", e); |
| } catch (IOException e) { |
| Log.w(TAG, "I/O exception reading touch sound assets", e); |
| } finally { |
| if (parser != null) { |
| parser.close(); |
| } |
| } |
| } |
| |
| private int findOrAddResourceByFileName(String fileName) { |
| for (int i = 0; i < mResources.size(); i++) { |
| if (mResources.get(i).mFileName.equals(fileName)) { |
| return i; |
| } |
| } |
| int result = mResources.size(); |
| mResources.add(new Resource(fileName)); |
| return result; |
| } |
| |
| private Resource findResourceBySampleId(int sampleId) { |
| for (Resource res : mResources) { |
| if (res.mSampleId == sampleId) { |
| return res; |
| } |
| } |
| return null; |
| } |
| |
| private class SfxWorker extends Thread { |
| SfxWorker() { |
| super("AS.SfxWorker"); |
| } |
| |
| @Override |
| public void run() { |
| Looper.prepare(); |
| synchronized (SoundEffectsHelper.this) { |
| mSfxHandler = new SfxHandler(); |
| SoundEffectsHelper.this.notify(); |
| } |
| Looper.loop(); |
| } |
| } |
| |
| private class SfxHandler extends Handler { |
| @Override |
| public void handleMessage(Message msg) { |
| switch (msg.what) { |
| case MSG_LOAD_EFFECTS: |
| onLoadSoundEffects((OnEffectsLoadCompleteHandler) msg.obj); |
| break; |
| case MSG_UNLOAD_EFFECTS: |
| onUnloadSoundEffects(); |
| break; |
| case MSG_PLAY_EFFECT: |
| final int effect = msg.arg1, volume = msg.arg2; |
| onLoadSoundEffects(new OnEffectsLoadCompleteHandler() { |
| @Override |
| public void run(boolean success) { |
| if (success) { |
| onPlaySoundEffect(effect, volume); |
| } |
| } |
| }); |
| break; |
| case MSG_LOAD_EFFECTS_TIMEOUT: |
| if (mSoundPoolLoader != null) { |
| mSoundPoolLoader.onTimeout(); |
| } |
| break; |
| } |
| } |
| } |
| |
| private class SoundPoolLoader implements |
| android.media.SoundPool.OnLoadCompleteListener { |
| |
| private List<OnEffectsLoadCompleteHandler> mLoadCompleteHandlers = |
| new ArrayList<OnEffectsLoadCompleteHandler>(); |
| |
| SoundPoolLoader() { |
| // SoundPool use the current Looper when creating its message handler. |
| // Since SoundPoolLoader is created on the SfxWorker thread, SoundPool's |
| // message handler ends up running on it (it's OK to have multiple |
| // handlers on the same Looper). Thus, onLoadComplete gets executed |
| // on the worker thread. |
| mSoundPool.setOnLoadCompleteListener(this); |
| } |
| |
| void addHandler(OnEffectsLoadCompleteHandler handler) { |
| if (handler != null) { |
| mLoadCompleteHandlers.add(handler); |
| } |
| } |
| |
| @Override |
| public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { |
| if (status == 0) { |
| int remainingToLoad = 0; |
| for (Resource res : mResources) { |
| if (res.mSampleId == sampleId && !res.mLoaded) { |
| logEvent("effect " + res.mFileName + " loaded"); |
| res.mLoaded = true; |
| } |
| if (res.mSampleId != EFFECT_NOT_IN_SOUND_POOL && !res.mLoaded) { |
| remainingToLoad++; |
| } |
| } |
| if (remainingToLoad == 0) { |
| onComplete(true); |
| } |
| } else { |
| Resource res = findResourceBySampleId(sampleId); |
| String filePath; |
| if (res != null) { |
| filePath = getResourceFilePath(res); |
| } else { |
| filePath = "with unknown sample ID " + sampleId; |
| } |
| logEvent("effect " + filePath + " loading failed, status " + status); |
| Log.w(TAG, "onLoadSoundEffects(), Error " + status + " while loading sample " |
| + filePath); |
| onComplete(false); |
| } |
| } |
| |
| void onTimeout() { |
| onComplete(false); |
| } |
| |
| void onComplete(boolean success) { |
| if (mSoundPool != null) { |
| mSoundPool.setOnLoadCompleteListener(null); |
| } |
| for (OnEffectsLoadCompleteHandler handler : mLoadCompleteHandlers) { |
| handler.run(success); |
| } |
| logEvent("effects loading " + (success ? "completed" : "failed")); |
| } |
| } |
| } |