blob: cf5bc8d88c73ef064e614d922fbf9bd5d8d32bf4 [file] [log] [blame]
/*
* 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 (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:
onLoadSoundEffects(new OnEffectsLoadCompleteHandler() {
@Override
public void run(boolean success) {
if (success) {
onPlaySoundEffect(msg.arg1 /*effect*/, msg.arg2 /*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) {
mSoundPool.setOnLoadCompleteListener(null);
for (OnEffectsLoadCompleteHandler handler : mLoadCompleteHandlers) {
handler.run(success);
}
logEvent("effects loading " + (success ? "completed" : "failed"));
}
}
}