/* * Copyright (C) 2014 Fastboot Mobile, LLC. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See * the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program; * if not, see <http://www.gnu.org/licenses>. */ package com.fastbootmobile.encore.framework; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.SystemClock; import android.support.annotation.NonNull; import android.util.Log; import com.fastbootmobile.encore.app.BuildConfig; import com.fastbootmobile.encore.utils.WaveHeader; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Class helping audio fingerprinting for recognition */ public class EchoPrint { private static final String TAG = "EchoPrint"; private static final boolean DEBUG = BuildConfig.DEBUG; private static final String USER_AGENT = "User-Agent: AppNumber=48000 APIVersion=2.1.0.0 DEV=Android UID=dkl109sas19s"; private static final String MIME_TYPE = "audio/wav"; private static final int SAMPLE_RATE = 11025; private static final short BIT_DEPTH = 16; private static final short CHANNELS = 1; private static final int TRY_MATCH_INTERVAL = 3000; // Try getting a match every 3 seconds /** * Helper thread class to record the data to send */ private class RecorderThread extends Thread { private boolean mDataSending = false; private boolean mResultGiven = false; private long mLastMatchTryTime; public void run() { if (DEBUG) Log.d(TAG, "Started recording reading..."); mLastMatchTryTime = SystemClock.uptimeMillis(); while (!isInterrupted() && mBufferIndex < mBuffer.length) { int read; synchronized (this) { read = mRecorder.read(mBuffer, mBufferIndex, Math.min(512, mBuffer.length - mBufferIndex)); if (read == AudioRecord.ERROR_BAD_VALUE) { Log.e(TAG, "BAD_VALUE while reading recorder"); break; } else if (read == AudioRecord.ERROR_INVALID_OPERATION) { Log.e(TAG, "INVALID_OPERATION while reading recorder"); break; } else if (read >= 0) { mBufferIndex += read; } } if (read >= 0) { if (mBufferIndex > 10) { mCallback.onAudioLevel((float) computeAverageAmplitude(mBuffer, mBufferIndex - 10, 4)); } long currentTime = SystemClock.uptimeMillis(); if (currentTime - mLastMatchTryTime >= TRY_MATCH_INTERVAL) { tryMatchCurrentBuffer(); mLastMatchTryTime = SystemClock.uptimeMillis(); } } } if (DEBUG) Log.d(TAG, "Broke out of recording loop, mResultGiven=" + mResultGiven); if (!mResultGiven) { tryMatchCurrentBuffer(); } } private double computeAverageAmplitude(byte[] buffer, int startSample, int numSamples) { // Assuming 16 bits depth double sum = 0.0f; for (int i = 0; i < numSamples; ++i) { sum += computeAmplitude(buffer, startSample + i * 2); } return sum / ((double) numSamples); } private double computeAmplitude(byte[] buffer, int sample) { // Assuming 16 bits depth int amplitude = (buffer[sample] & 0xff) << 8 | buffer[sample + 1]; // decibel: return 20.0 * Math.log10((double)Math.abs(amplitude) / 32768.0); return amplitude / 65536.0; } public void tryMatchCurrentBuffer() { if (mBufferIndex > 0) { new Thread() { public void run() { // Allow only one upload call at a time if (mDataSending) { Log.d(TAG, "Not sending, data already sending"); return; } mDataSending = true; byte[] copy; int length; synchronized (RecorderThread.this) { length = mBufferIndex; } copy = new byte[length]; System.arraycopy(mBuffer, 0, copy, 0, length); String output_xml = sendAudioData(copy, length); parseXmlResult(output_xml); mDataSending = false; } }.start(); } else { Log.e(TAG, "0 bytes recorded!?"); } } private String sendAudioData(byte[] inputBuffer, int length) { if(DEBUG) Log.d(TAG, "Preparing to send audio data: " + length + " bytes"); try { URL url = new URL("http://search.midomi.com:443/v2/?method=search&type=identify"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.addRequestProperty("User-Agent", USER_AGENT); conn.addRequestProperty("Content-Type", MIME_TYPE); conn.setDoOutput(true); conn.setConnectTimeout(4000); conn.setReadTimeout(10000); // Write the WAVE audio header, then the PCM data if (DEBUG) Log.d(TAG, "Sending mic data, " + length + " bytes..."); WaveHeader header = new WaveHeader(WaveHeader.FORMAT_PCM, CHANNELS, SAMPLE_RATE, BIT_DEPTH, length); header.write(conn.getOutputStream()); conn.getOutputStream().write(inputBuffer, 0, length); InputStream is = conn.getInputStream(); byte[] buffer = new byte[8192]; int read; StringBuilder sb = new StringBuilder(); while ((read = is.read(buffer)) > 0) { sb.append(new String(buffer, 0, read)); } return sb.toString(); } catch (IOException e) { Log.e(TAG, "Error while sending audio data", e); mDataSending = false; } return ""; } private void parseXmlResult(String xml) { if (xml.contains("did not hear any music") || xml.contains("no close matches")) { // No result if (DEBUG) Log.d(TAG, "No match (did not hear/no close)"); reportResult(null); } else { // Return result where everything is fine PrintResult result = new PrintResult(); Pattern data_re = Pattern.compile("<track .*?artist_name=\"(.*?)\".*?album_name=\"(.*?)\".*?track_name=\"(.*?)\".*?album_primary_image=\"(.*?)\".*?>", Pattern.DOTALL | Pattern.MULTILINE); Matcher match = data_re.matcher(xml.replaceAll("\n", "")); if (match.find()) { result.ArtistName = match.group(1); result.AlbumName = match.group(2); result.TrackName = match.group(3); result.AlbumImageUrl = match.group(4); if (DEBUG) Log.d(TAG, "Got a match! " + result); } else { Log.w(TAG, "Regular expression didn't match!"); reportResult(null); } reportResult(result); } } private void reportResult(PrintResult result) { // If the recording is still active and we have no match, don't do anything. Otherwise, // report the result. if (mRecorder == null && result == null) { if (DEBUG) Log.d(TAG, "Reporting onNoMatch"); mCallback.onNoMatch(); } else if (result != null) { if (DEBUG) Log.d(TAG, "Reporting result"); mResultGiven = true; if (mRecorder != null) { stopRecording(); } mCallback.onResult(result); } } } /** * Class storing fingerprinting results */ public static class PrintResult { public String ArtistName; public String AlbumName; public String TrackName; public String AlbumImageUrl; @Override public String toString() { return ArtistName + " - " + TrackName + " (" + AlbumName + "); " + AlbumImageUrl; } } /** * Interface for receiving results and information during printing */ public interface PrintCallback { /** * Called when the API returns a match * * @param result The matched track */ void onResult(PrintResult result); /** * Called when the API returned no match */ void onNoMatch(); /** * Called frequently with the audio level * * @param level The microphone audio level, between 0 and 1 */ void onAudioLevel(float level); /** * Called when an error occurred */ void onError(); } private byte[] mBuffer; private int mBufferIndex; private AudioRecord mRecorder; private RecorderThread mRecThread; private PrintCallback mCallback; /** * Creates an EchoPrint matching instances and allocates audio buffers */ public EchoPrint(@NonNull PrintCallback callback) { mCallback = callback; // We limit to 20 seconds of recording. We size our buffer to store 20 seconds of // audio at 11025 Hz, 16 bits (2 bytes) ; total of 430KB uploaded max int bufferSize = SAMPLE_RATE * 20 * 2; mBuffer = new byte[bufferSize]; } /** * Starts the recording and the recorder thread. Results will be posted in the PrintCallback * that was given to the class constructor. */ public void startRecording() { final int minBufSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); mRecorder = new AudioRecord(MediaRecorder.AudioSource.CAMCORDER, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBufSize); mBufferIndex = 0; try { mRecorder.startRecording(); mRecThread = new RecorderThread(); mRecThread.start(); } catch (IllegalStateException e) { Log.e(TAG, "Cannot start recording for recognition", e); mCallback.onError(); } } /** * If startRecording was called and still active, the recording system will be stopped and * pending data, if any, will be sent to the API to get a match. */ public void stopRecording() { if (mRecThread != null && mRecThread.isAlive()) { if (DEBUG) Log.d(TAG, "Interrupting recorder thread"); mRecThread.interrupt(); } if (mRecorder != null) { if (DEBUG) Log.d(TAG, "Stopping recorder"); mRecorder.stop(); mRecorder = null; } } }