piątek, 15 kwietnia 2016

[DSP2016] Android prosto z poligonu odc.8 (FFT, Visualizer, Broadcast Receiver, LocalBroadcastManager)

Dziś moc jest wyjątkowo z nami! Na githubie mamy już działający kolorofon! Co prawda, póki co przynajmniej, światła są jedynie rysowane na ekranie telefonu, ale klimat lat 80-tych można już poczuć.

Zakładałem, że dłużej z tym zejdzie, ale kilka szczęśliwych traf-ów doprowadziło mnie do tego w krótszym czasie.

Na początku może kilka słów o szybkiej transformacie Fouriera, oznaczanej skrótem FFT. Każdy dźwięk możemy za jej pomocą rozłożyć na składowe częstotliwości, co często używane jest przy wszelkich analizach jego spektrum, equalizerach  czy wizualizacjach. To duży dość temat, któremu jakiś czas temu się przyglądałem, teraz jednak chciałbym skoncentrować się na innych rzeczach. Zainteresowanych mogę odesłać np. do bardzo dobrego artykułu w pliku https://learn.adafruit.com/downloads/pdf/fft-fun-with-fourier-transforms.pdf, który oprócz prezentowania ciekawych zagadnień związanych z hardware zawiera także całkiem dobre wprowadzenie do problematyki analizy dźwięku za pomocą FFT.

Wróćmy jednak do Androida. Jak przetwarzać dźwięk w czasie rzeczywistym podczas jego odtwarzania?  Jak zaimplementować FFT? Implementacja FFT “od zera” nie jest taka prosta, tym bardziej że są różne jej odmiany, różne algorytmy. Jest też trochę bibliotek, z których możemy skorzystać. Za takimi “trzecimi” rozwiązaniami oglądąłem się kiedyś w Windows 10, ale w przypadku Androida wyczaiłem, że można skorzystać ze standardowego API udostępnianego przez samą platformę (z iOS też dostajemy API).  W LocalPlayback.java dodałem kilka linijek kodu:

private Visualizer mVisualizer;
private void createMediaPlayerIfNeeded() {    
if (mMediaPlayer == null) {

mVisualizer = new Visualizer(mMediaPlayer.getAudioSessionId());
mVisualizer.setCaptureSize(Visualizer.getCaptureSizeRange()[1]);
mVisualizer.setDataCaptureListener(this, Visualizer.getMaxCaptureRate() / 2, false, true);
mVisualizer.setEnabled(true);

} else { … }
}
@Override
public void onWaveFormDataCapture(Visualizer visualizer, byte[] waveform, int samplingRate) {
}

@Override
public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) {
if (mCallback != null)
mCallback.onFftDataCapture(visualizer, fft, samplingRate);
}

Po utworzeniu obiektu MediaPlayer możemy powiązać jego sesję audio z obiektem klasy Visualizer, którego zadaniem jest dostarczanie danych służących do wizualizacji muzyki. Cały LocalPlayback uczyniłem listenerem implementującym interfejs Visualizer.OnDataCaptureListener. W metodzie onFftDataCapture dostajemy wszystko co potrzebujemy, czyli wynik transformaty FFT na próbce odtwarzanej muzyki. W trakcie odtwarzania taki callback jest wywoływany wiele razy w ciągu jednej sekundy (u mnie kilkanaście).

Musimy jakoś przekazać ten event do naszego serwisu w tle MusicService, który będzie notyfikował aktywność z trzema światłami. Zmienna mCallback jest naszym interfejsem Callback, do którego dodałem metodę onFftDataCapture. U nas PlaybackManager zarządza Playback-iem (LocalPlayback), jest listenerem dla ostatnio wspomnianego zdarzenia. Sam też operuje z kolei callbackiem typu PlaybackServiceCallback, którego implementuje MusicService. Po dodaniu takiej samęj metody w tych miejscach możemy w końcu odbierać w MusicService na bieżąco dane FFT w miarę odtwarzania muzyki:

@Override
public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) {
mLightOrganProcessor.processFftData(visualizer, fft, samplingRate);
}

W serwisie utworzyliśmy obiekt nowej klasy LightOrganProcessor. W jej wnętrzu zawarłem logikę tłumacząco tablicę FFT na wartości od 0 do 1 (dokładnie od 0.05, by całkowicie nie gasić świateł) dla trzech kanałów: tonów niskich, średnich i wysokich. Generalnie liczona jest uśredniona energia sygnału dla każdej próbki jako amplituda liczb zespolonych z transformaty (tak wiem brzmi to groźnie, ale to proste jak liczenie przekątnej w prostokącie w szkole podstawowej, tyle że o boku rzeczywistym i urojonym –Winking smile). Następnie liczę jaka to jest część założonego maksimum dla danego kanału (wziąłęm z głowy na podstawie obserwacji co mi się pojawiało przy kilku utworach). Skorzystałem w pewnej mierze z http://stackoverflow.com/questions/27599308/android-visualizer-detect-beat-and-visualizer-setcapturesize-not-working-alt.  Po pewnych przeróbkach przerodziło się to w następującą postać:

public class LightOrganProcessor {

private static final float LOW_MAX_VALUE = 3000;
private static final float MID_MAX_VALUE = 3000;
private static final float HIGH_MAX_VALUE = 1500;

private static final int LOW_FREQUENCY = 50;
private static final int MID_FREQUENCY = 3000;
private static final int HIGH_FREQUENCY = 16000;

private float mBassLevelAcc;
private float mMidLevelAcc;
private float mTrebleLevelAcc;

private int mNumberOfSamplesInOneSec;
private long mSystemTimeStartSec;

private LightOrganProcessorCallback mCallback;

public LightOrganProcessor(LightOrganProcessorCallback callback) {
mCallback = callback;
}

public void processFftData(Visualizer visualizer, byte[] fft, int samplingRate) {

if (mSystemTimeStartSec == 0)
mSystemTimeStartSec = System.currentTimeMillis();

//bass
int energySum = 0;
int k = 2;
double captureSize = visualizer.getCaptureSize() / 2;
int sampleRate = visualizer.getSamplingRate() / 2000;

double nextFrequency = ((k / 2) * sampleRate) / (captureSize);
while (nextFrequency < LOW_FREQUENCY) {
energySum += getAmplitude(fft[k], fft[k + 1]);
k += 2;
nextFrequency = ((k / 2) * sampleRate) / (captureSize);
}
double sampleAvgAudioEnergy = (double) energySum / (double) ((k * 1.0) / 2.0);
mBassLevelAcc += getRatioAmplitude(sampleAvgAudioEnergy, LOW_MAX_VALUE);


//mid
energySum = 0;
while (nextFrequency < MID_FREQUENCY) {
energySum += getAmplitude(fft[k], fft[k + 1]);
k += 2;
nextFrequency = ((k / 2) * sampleRate) / (captureSize);
}
sampleAvgAudioEnergy = (double) energySum / (double) ((k * 1.0) / 2.0);
mMidLevelAcc += getRatioAmplitude(sampleAvgAudioEnergy, MID_MAX_VALUE);


//treble
energySum = 0;

while ((nextFrequency < HIGH_FREQUENCY) && (k < fft.length)) {
energySum += getAmplitude(fft[k], fft[k + 1]);
k += 2;
nextFrequency = ((k / 2) * sampleRate) / (captureSize);
}
sampleAvgAudioEnergy = (double) energySum / (double) ((k * 1.0) / 2.0);
mTrebleLevelAcc += getRatioAmplitude(sampleAvgAudioEnergy, HIGH_MAX_VALUE);


mNumberOfSamplesInOneSec++;

if ((System.currentTimeMillis() - mSystemTimeStartSec) > 100) {

LightOrganData data = new LightOrganData();
data.bassLevel = mBassLevelAcc / mNumberOfSamplesInOneSec;
data.midLevel = mMidLevelAcc / mNumberOfSamplesInOneSec;
data.trebleLevel = mTrebleLevelAcc / mNumberOfSamplesInOneSec;

if (mCallback != null)
mCallback.onLightOrganDataUpdated(data);

mNumberOfSamplesInOneSec = 0;
mBassLevelAcc = 0;
mMidLevelAcc = 0;
mTrebleLevelAcc = 0;
mSystemTimeStartSec = System.currentTimeMillis();
}
}

private static double getAmplitude(byte r, byte i) {
return Math.sqrt(r * r + i * i);
}

private static double getRatioAmplitude(double energy, double maxValue) {
double value = energy * 1000;
if (value > maxValue)
value = maxValue;

double v = value / maxValue;

if (v < 0.05)
v = 0.05;

return v;
}


public interface LightOrganProcessorCallback {

void onLightOrganDataUpdated(LightOrganData data);
}
}
Próbowałem też podejścia z przeliczaniem amplitudy/energii na decybele, by jakoś szukać granic. Decybele są choćby używane w wizualizacji pasków w całkiem dobrym, prostym projekcie na https://github.com/felixpalmer/android-visualizer. Mi jednak jakoś to podejście mniej teraz podeszło (jak estymować granice w często ujemnych decybelach?) i na razie przynajmniej nie widzę konieczności jego stosowania (może kiedyś do tego powrócimy). Widziałem swego czasu kiedyś cyfrową iluminofonię bez przeliczania na db…
Możemy otrzymać wiele uśrednionych wartości dla kanału w ciągu jednej sekundy. Poprzez zapamiętywanie czasu i wyzwalanie metod z kolejnego callbacka, to ja decyduję kiedy MusicService otrzyma informację o zmianie wysterowania świateł. Przy 1s (1000ms) jest wrażenie jakby telefon umierał, przy zerze może to migotowanie jest już ciut denerwujące, 100ms - moze subiektywnie - dało mi pozytywne wrażenie dużej responsywności i sprawnego reagowania, a jednocześnie z takim lekkim utrwalaniem błysków (oko ludzkie rejestruje miganie do jakiegoś stopnia, w kolorofonie z zeszłego wieku żarówki miały też swoje opóźnienie).
Teraz do rozwiązanie kolejne zadanie. Jak z serwisu przekazywać informacje dla świateł w MainActivity? Sposobów komunikacji pomiędzy serwisem a aktywnością jest kilka. W grę wchodzi bindowanie serwisu, broadcast receiver lub messenger. Broadcast receiver wydaje się dość wygodny (mała powtórka choćby na podstawie części kursu Handling System Notfications with Broadcast Receivers), ale jest pewnym obciążeniem, może docierać do innych aplikacji i komponentów w systemie. Wykorzystałem lokalną odmianę, która przy pomocy podobnego API nawiązuje lżejszą lokalną komunikację w obrębie tej samej aplikacji. Można sobie przeczytać o tym artykuł dostępny na http://www.intertech.com/Blog/using-localbroadcastmanager-in-service-to-activity-communications/. Koniec końców komunikacja u mnie wygląda następująco. W MusicService wywoływana jest przez LightOrganProcessor metoda:
@Override
public void onLightOrganDataUpdated(LightOrganData data) {
Intent broadcastIntent = new Intent();
broadcastIntent.setAction(MusicService.ACTION_LIGHT_ORGAN_DATA_CHANGED);
broadcastIntent.putExtra(BASS_LEVEL, data.bassLevel);
broadcastIntent.putExtra(MID_LEVEL, data.midLevel);
broadcastIntent.putExtra(TREBLE_LEVEL, data.trebleLevel);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
}
Broadcastujemy intencję (w MusicService zdefiniowałem stałe dla nazwy akcji i parametrów).  Aby to odebrać w MainActivity piszę taki sam broadcast receiver, jak przy tradyjnym cięższym broadcaście:
private final BroadcastReceiver mLightOrganReceiver = new BroadcastReceiver() {

@Override
public void onReceive(Context context, Intent intent) {

float b = intent.getFloatExtra(MusicService.BASS_LEVEL,0);
float m = intent.getFloatExtra(MusicService.MID_LEVEL,0);
float t = intent.getFloatExtra(MusicService.TREBLE_LEVEL,0);

setLight(R.id.bass_light, b);
setLight(R.id.mid_light, m);
setLight(R.id.treble_light, t);

}
};
Wygodne są tutaj właściwości klas zagnieżdżonych w Javie, które pozwalają na odwoływanie się do elementów rodzica z ciała klasy. Broadcast receiver potrzebujemy w odpowiednim momencie życia aktywności zarejestrować i wyrejestrować. Dla LocalBroadcastManager’a robimy to podobnie do klasycznego broadcastu: 
@Override
protected void onResume() {
super.onResume();

IntentFilter intentFilter = new IntentFilter(MusicService.ACTION_LIGHT_ORGAN_DATA_CHANGED);
LocalBroadcastManager.getInstance(this).registerReceiver(mLightOrganReceiver, intentFilter);
}

@Override
protected void onPause() {
super.onPause();

LocalBroadcastManager.getInstance(this).unregisterReceiver(mLightOrganReceiver);
}
Na tym etapie iluminofonia zaczęła mi działać, ale … po iluś błyśnięciach mi stopniowo gasła. Rozważałem różne złe rzeczy, w końcu włączyłem logowanie przychodzących liczb do MainActivity. Okazało się, że liczby cały czas przychodzą, tak jak powinny!  Renderowanie nie wydala? NIE!  To banalna pomyłka w metodzie getColorWithAlpha z poprzedniego odcinka. Liczy ona przezroczystość w stosunku do poprzedniego stanu przezroczystości! Jak to poprawić? Wystarczy liczyć zawsze część od maksimum dla kanału alfa, w tym przypadku to liczba 255:
private static int getColorWithAlpha(int color, float ratio) {
int newColor = 0;
int alpha = Math.round(255 * ratio);
int r = Color.red(color);
int g = Color.green(color);
int b = Color.blue(color);
newColor = Color.argb(alpha, r, g, b);
return newColor;
}
Teraz działa jak powinno!  
Aha, jest jeszcze kwestia uprawnień. By działał Visualizer aplikacja musi mieć nadane uprawnienie RECORD_AUDIO. Brzmi to trochę dziwnie, ale tutaj ktoś uważa, że wizualizacja nas podsłuchuje i może w ten sposób naruszyć naszą prywatność! Musimy jako użytkownik wyrazić na to zgodę. W dodatku uprawnienie to nie jest bezpieczne, więc w Android 6 sytuacja wygląda podobnie jak przy READ_EXTERNAL_STORAGE. W MusicService w podobnym miejscu sprawdzam na czym stoję. W BaseActivity przy proszeniu usera o zezwolenie nie będziemy jednak nękać usera dwoma osobnymi requestami (inna sprawa że Android 6 w jednym requeście potrafi wyświetlić kilka pytań-Winking smile), sprawdzamy co mamy i prosimy o to, czego nam brakuje. W jednej metodzie także podejmujemy odpowiednie czynności w zależności od tego, o co prosiliśmy i co otrzymaliśmy. Nie przedstawiam tutaj tego już szczegółowo (kto chce może zajrzeć to kodu), ale wzorowałem się w pewnym stopniu na rozwiązaniach opisanych w artykule http://inthecheesefactory.com/blog/things-you-need-to-know-about-android-m-permission-developer-edition/en, który już kiedyś wymieniałem. 

Brak komentarzy: