OboeTester: add EchoActivity
diff --git a/apps/OboeTester/app/src/main/AndroidManifest.xml b/apps/OboeTester/app/src/main/AndroidManifest.xml
index edbfa6e..c393f56 100644
--- a/apps/OboeTester/app/src/main/AndroidManifest.xml
+++ b/apps/OboeTester/app/src/main/AndroidManifest.xml
@@ -61,6 +61,12 @@
             android:screenOrientation="portrait">
         </activity>
 
+        <activity
+            android:name="com.google.sample.oboe.manualtest.EchoActivity"
+            android:label="@string/title_activity_echo"
+            android:screenOrientation="portrait">
+        </activity>
+
         <service
             android:name="com.google.sample.oboe.manualtest.AudioMidiTester"
             android:permission="android.permission.BIND_MIDI_DEVICE_SERVICE">
diff --git a/apps/OboeTester/app/src/main/cpp/SawPingGenerator.cpp b/apps/OboeTester/app/src/main/cpp/SawPingGenerator.cpp
index 0934592..22c80f9 100644
--- a/apps/OboeTester/app/src/main/cpp/SawPingGenerator.cpp
+++ b/apps/OboeTester/app/src/main/cpp/SawPingGenerator.cpp
@@ -15,9 +15,11 @@
  */
 
 #include <unistd.h>
+#include "common/OboeDebug.h"
 #include "oboe/Definitions.h"
 #include "SawPingGenerator.h"
 
+
 using namespace flowgraph;
 
 SawPingGenerator::SawPingGenerator()
@@ -41,6 +43,7 @@
     float *buffer = output.getBuffer();
 
     if (mRequestCount.load() > mAcknowledgeCount.load()) {
+        LOGD("%s() request count = %d", __func__, mRequestCount.load());
         mPhase = -1.0f;
         mLevel = 1.0;
         mAcknowledgeCount++;
@@ -63,6 +66,7 @@
 }
 
 void SawPingGenerator::setEnabled(bool enabled) {
+    LOGD("%s(%d)", __func__, enabled ? 1 : 0);
     if (enabled) {
         mRequestCount++;
     }
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioOutputTester.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioOutputTester.java
index 936aff2..9e6df8a 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioOutputTester.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioOutputTester.java
@@ -41,6 +41,7 @@
     }
 
     public void setToneType(int index) {
+        Log.i(TapToToneActivity.TAG, "setToneType(" + index + ")");
         mOboeAudioOutputStream.setToneType(index);
     }
 
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioStreamBase.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioStreamBase.java
index 2f83d25..8ff04fd 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioStreamBase.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/AudioStreamBase.java
@@ -51,6 +51,32 @@
         public double latency; // msec
         public int state;
         public long callbackCount;
+
+
+        // These are constantly changing.
+        String dump(int framesPerBurst) {
+            if (bufferSize < 0 || framesWritten < 0) {
+                return "idle";
+            }
+            int numBuffers = 0;
+            if (bufferSize > 0 && framesPerBurst > 0) {
+                numBuffers = bufferSize / framesPerBurst;
+            }
+            String latencyText = (latency < 0.0)
+                    ? "?"
+                    : String.format("%6.1f msec", latency);
+            return "buffer size = "
+                    + ((bufferSize < 0) ? "?" : bufferSize) + " = "
+                    + numBuffers + " * " + framesPerBurst + ", xRunCount = "
+                    +  ((xRunCount < 0) ? "?" : xRunCount) + "\n"
+                    + "frames written " + framesWritten + " - read " + framesRead
+                    + " = " + (framesWritten - framesRead) + "\n"
+
+                    + "latency = " + latencyText
+                    + ", state = " + state
+                    + ", #callbacks " + callbackCount
+                    ;
+        }
     }
 
     public void open(StreamConfiguration requestedConfiguration,
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/EchoActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/EchoActivity.java
index 6721b53..ae2a528 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/EchoActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/EchoActivity.java
@@ -21,7 +21,7 @@
 /**
  * Activity to record and play back audio.
  */
-public class EchoActivity extends TestInputActivity {
+public class EchoActivity extends TestAudioActivity {
 
     private static final int STATE_RECORDING = 5;
     private static final int STATE_RUNNING = 6;
@@ -29,7 +29,7 @@
 
     @Override
     protected void inflateActivity() {
-        setContentView(R.layout.activity_recorder);
+        setContentView(R.layout.activity_echo);
     }
 
     public void onStartEcho(View view) {
@@ -46,15 +46,22 @@
 
     public void startPlayback() {
         try {
-            mAudioStreamTester.startPlayback();
+            // mAudioStreamTester.startPlayback();
             updateStreamConfigurationViews();
             updateEnabledWidgets();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
 
     }
 
+    @Override
+    boolean isOutput() {
+        return false;
+    }
+
+    @Override
+    public void setupEffects(int sessionId) {
+    }
 }
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
index 8cf7b3b..175a3fa 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/MainActivity.java
@@ -115,6 +115,12 @@
         startActivity(intent);
     }
 
+    public void onLaunchEcho(View view) {
+        updateCallbackSize();
+        Intent intent = new Intent(this, EchoActivity.class);
+        startActivity(intent);
+    }
+
     public void onUseCallbackClicked(View view) {
         CheckBox checkBox = (CheckBox) view;
         OboeAudioStream.setUseCallback(checkBox.isChecked());
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
index a4aead2..00dcdca 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/RecorderActivity.java
@@ -60,12 +60,11 @@
 
     public void startPlayback() {
         try {
-            mAudioStreamTester.startPlayback();
+            mAudioInputTester.startPlayback();
             updateStreamConfigurationViews();
             updateEnabledWidgets();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
 
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfigurationView.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfigurationView.java
index fbddcb5..f0b8754 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfigurationView.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/StreamConfigurationView.java
@@ -49,7 +49,6 @@
     private TextView mActualPerformanceView;
     private Spinner  mPerformanceSpinner;
     private CheckBox mRequestedExclusiveView;
-    private TextView mStreamInfoView;
     private Spinner  mChannelCountSpinner;
     private TextView mActualChannelCountView;
     private TextView mActualFormatView;
@@ -62,6 +61,8 @@
     private TextView mActualSessionIdView;
     private CheckBox mRequestAudioEffect;
 
+    private TextView mStreamInfoView;
+    private TextView mStreamStatusView;
 
     public StreamConfigurationView(Context context) {
         super(context);
@@ -141,6 +142,8 @@
 
         mStreamInfoView = (TextView) findViewById(R.id.streamInfo);
 
+        mStreamStatusView = (TextView) findViewById(R.id.statusView);
+
         mDeviceSpinner = (AudioDeviceSpinner) findViewById(R.id.devices_spinner);
         mDeviceSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
             @Override
@@ -239,6 +242,7 @@
         mRequestAudioEffect.setEnabled(enabled);
     }
 
+    // This must be called on the UI thread.
     void updateDisplay() {
         int value;
 
@@ -269,6 +273,11 @@
         mOptionTable.requestLayout();
     }
 
+    // This must be called on the UI thread.
+    public void setStatusText(String msg) {
+        mStreamStatusView.setText(msg);
+    }
+
     public StreamConfiguration getRequestedConfiguration() {
         return mRequestedConfiguration;
     }
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TapToToneActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TapToToneActivity.java
index cdf87ea..723c7be 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TapToToneActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TapToToneActivity.java
@@ -61,14 +61,18 @@
     private int mLatencyMax;
 
     @Override
+    protected void inflateActivity() {
+        setContentView(R.layout.activity_tap_to_tone);
+    }
+
+    @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_tap_to_tone);
+
+        mAudioOutTester = addAudioOutputTester();
 
         mResultView = (TextView) findViewById(R.id.resultView);
 
-        findAudioCommon();
-
         if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_MIDI)) {
             setupMidi();
         } else {
@@ -158,7 +162,7 @@
         public void onNoteOn(final int pitch) {
             runOnUiThread(new Runnable() {
                 public void run() {
-                    mStatusView.setText("MIDI pitch = " + pitch);
+                    mStreamContexts.get(0).configurationView.setStatusText("MIDI pitch = " + pitch);
                 }
             });
         }
@@ -226,7 +230,7 @@
                             mInputPort = device.openInputPort(0);
                             Log.i(TAG, "opened MIDI port = " + mInputPort + " on " + info);
                             mAudioMidiTester = AudioMidiTester.getInstance();
-                            mAudioStreamTester = mAudioOutTester = AudioOutputTester.getInstance();
+
                             Log.i(TAG, "openPort() mAudioMidiTester = " + mAudioMidiTester);
                             // Now that we have created the AudioMidiTester, close the port so we can
                             // open it later.
@@ -338,7 +342,11 @@
         resetLatency();
         try {
             mAudioMidiTester.start();
-            mAudioOutTester.setToneType(OboeAudioOutputStream.TONE_TYPE_SAW_PING);
+            if (mAudioOutTester != null) {
+                mAudioOutTester.setToneType(OboeAudioOutputStream.TONE_TYPE_SAW_PING);
+            } else {
+                Log.w(TAG, "startAudioPermitted, mAudioOutTester = null, cannot setToneType(ping)");
+            }
         } catch (IOException e) {
             e.printStackTrace();
         }
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
index 3d3455e..e8501f5 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestAudioActivity.java
@@ -19,17 +19,15 @@
 import android.app.Activity;
 import android.content.Context;
 import android.media.AudioManager;
+import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.util.Log;
 import android.view.View;
 import android.widget.Button;
 import android.widget.CheckBox;
-import android.widget.TextView;
 import android.widget.Toast;
 
-import com.google.sample.oboe.manualtest.R;
-
 import java.io.IOException;
 import java.util.ArrayList;
 
@@ -49,11 +47,9 @@
     public static final int COLOR_IDLE = 0xFFD0D0D0;
 
     private int mState = STATE_CLOSED;
-    protected TextView mStatusView;
     protected String audioManagerSampleRate;
     protected int audioManagerFramesPerBurst;
-    protected AudioStreamTester mAudioStreamTester;
-    protected ArrayList<StreamConfigurationView> mStreamConfigurationViews;
+    protected ArrayList<StreamContext> mStreamContexts;
     private Button mOpenButton;
     private Button mStartButton;
     private Button mPauseButton;
@@ -62,22 +58,32 @@
     private MyStreamSniffer mStreamSniffer;
     private CheckBox mCallbackReturnStopBox;
 
+    public static class StreamContext {
+        StreamConfigurationView configurationView;
+        AudioStreamTester tester;
+    }
+
     // Periodically query the status of the stream.
     protected class MyStreamSniffer {
         public static final int SNIFFER_UPDATE_PERIOD_MSEC = 150;
         public static final int SNIFFER_UPDATE_DELAY_MSEC = 300;
 
-        private int mFramesPerBurst = 1;
-        private int mNumUpdates = 0;
         private Handler mHandler;
 
         // Display status info for the stream.
         private Runnable runnableCode = new Runnable() {
             @Override
             public void run() {
-                // Handler runs this on the main UI thread.
-                AudioStreamBase.StreamStatus status = mAudioStreamTester.getCurrentAudioStream().getStreamStatus();
-                updateStreamStatusView(status);
+
+                for (StreamContext streamContext : mStreamContexts) {
+                    // Handler runs this on the main UI thread.
+                    AudioStreamBase.StreamStatus status = streamContext.tester.getCurrentAudioStream().getStreamStatus();
+                    int framesPerBurst = streamContext.tester.getCurrentAudioStream().getFramesPerBurst();
+                    final String msg = status.dump(framesPerBurst);
+                    mStreamContexts.get(0).configurationView.setStatusText(msg);
+                    updateStreamDisplay();
+                }
+
                 // Repeat this runnable code block again.
                 mHandler.postDelayed(runnableCode, SNIFFER_UPDATE_PERIOD_MSEC);
             }
@@ -85,10 +91,8 @@
 
         private void startStreamSniffer() {
             stopStreamSniffer();
-            mNumUpdates = 0;
             mHandler = new Handler(Looper.getMainLooper());
             // Start the initial runnable task by posting through the handler
-            mFramesPerBurst = mAudioStreamTester.getCurrentAudioStream().getFramesPerBurst();
             mHandler.postDelayed(runnableCode, SNIFFER_UPDATE_DELAY_MSEC);
         }
 
@@ -98,46 +102,26 @@
             }
         }
 
-        // These are constantly changing.
-        private void updateStreamStatusView(final AudioStreamBase.StreamStatus status) {
-            if (status.bufferSize < 0 || status.framesWritten < 0) {
-                return;
-            }
-            int numBuffers = 0;
-            if (status.bufferSize > 0 && mFramesPerBurst > 0) {
-                numBuffers = status.bufferSize / mFramesPerBurst;
-            }
-            String latencyText = (status.latency < 0.0)
-                    ? "?"
-                    : String.format("%6.1f msec", status.latency);
-            final String msg = "buffer size = "
-                    + ((status.bufferSize < 0) ? "?" : status.bufferSize) + " = "
-                    + numBuffers + " * " + mFramesPerBurst + ", xRunCount = "
-                    +  ((status.xRunCount < 0) ? "?" : status.xRunCount) + "\n"
-                    + "frames written " + status.framesWritten + " - read " + status.framesRead
-                    + " = " + (status.framesWritten - status.framesRead) + "\n"
-
-                    + "# " + mNumUpdates++
-                    + ", latency = " + latencyText
-                    + ", state = " + status.state
-                    + ", #callbacks " + status.callbackCount
-                    ;
-            runOnUiThread(new Runnable() {
-                public void run() {
-                    mStatusView.setText(msg);
-                    updateStreamDisplay();
-                }
-            });
-        }
     }
 
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        inflateActivity();
+        findAudioCommon();
+    }
+
+    protected abstract void inflateActivity();
+
     void updateStreamDisplay() {
     }
 
     @Override
     protected void onDestroy() {
         try {
-            mAudioStreamTester.stop();
+            for (StreamContext streamContext : mStreamContexts) {
+                streamContext.tester.stop();
+            }
         } catch (IOException e) {
             e.printStackTrace();
         }
@@ -161,29 +145,48 @@
     }
 
     private void setConfigViewsEnabled(boolean b) {
-        for (StreamConfigurationView configView : mStreamConfigurationViews) {
-            configView.setChildrenEnabled(b);
+        for (StreamContext streamContext : mStreamContexts) {
+            streamContext.configurationView.setChildrenEnabled(b);
         }
     }
 
     abstract boolean isOutput();
 
-    void setupStreamConfigurationViews() {
-        StreamConfigurationView configView = (StreamConfigurationView)
+    public AudioOutputTester addAudioOutputTester() {
+        StreamContext streamContext = new StreamContext();
+        streamContext.configurationView =(StreamConfigurationView)
                 findViewById(R.id.outputStreamConfiguration);
-        configView.setOutput(isOutput());
-        mStreamConfigurationViews.add(configView);
+        if (streamContext.configurationView == null) {
+            streamContext.configurationView =(StreamConfigurationView)
+                    findViewById(R.id.streamConfiguration);
+        }
+        streamContext.configurationView.setOutput(true);
+        streamContext.tester = AudioOutputTester.getInstance();
+        mStreamContexts.add(streamContext);
+        return (AudioOutputTester) streamContext.tester;
+    }
+
+    public AudioInputTester addAudioInputTester() {
+        StreamContext streamContext = new StreamContext();
+        streamContext.configurationView =(StreamConfigurationView)
+                findViewById(R.id.inputStreamConfiguration);
+        if (streamContext.configurationView == null) {
+            streamContext.configurationView =(StreamConfigurationView)
+                    findViewById(R.id.streamConfiguration);
+        }
+        streamContext.configurationView.setOutput(false);
+        streamContext.tester = AudioInputTester.getInstance();
+        mStreamContexts.add(streamContext);
+        return (AudioInputTester) streamContext.tester;
     }
 
     void updateStreamConfigurationViews() {
-        for (StreamConfigurationView configView : mStreamConfigurationViews) {
-            configView.updateDisplay();
+        for (StreamContext streamContext : mStreamContexts) {
+            streamContext.configurationView.updateDisplay();
         }
     }
 
     protected void findAudioCommon() {
-        mStatusView = (TextView) findViewById(R.id.statusView);
-
         mOpenButton = (Button) findViewById(R.id.button_open);
         if (mOpenButton != null) {
             mStartButton = (Button) findViewById(R.id.button_start);
@@ -191,8 +194,7 @@
             mStopButton = (Button) findViewById(R.id.button_stop);
             mCloseButton = (Button) findViewById(R.id.button_close);
         }
-        mStreamConfigurationViews = new ArrayList<StreamConfigurationView>();
-        setupStreamConfigurationViews();
+        mStreamContexts = new ArrayList<StreamContext>();
 
         queryNativeAudioParameters();
 
@@ -254,11 +256,11 @@
 
     public void openAudio() {
         try {
-            for (StreamConfigurationView configView : mStreamConfigurationViews) {
+            for (StreamContext streamContext : mStreamContexts) {
+                StreamConfigurationView configView = streamContext.configurationView;
                 StreamConfiguration requestedConfig = configView.getRequestedConfiguration();
                 requestedConfig.setFramesPerBurst(audioManagerFramesPerBurst);
-                mAudioStreamTester.open(requestedConfig,
-                        configView.getActualConfiguration());
+                streamContext.tester.open(requestedConfig, configView.getActualConfiguration());
                 mState = STATE_OPEN;
                 int sessionId = configView.getActualConfiguration().getSessionId();
                 if (sessionId > 0) {
@@ -270,7 +272,6 @@
             mStreamSniffer.startStreamSniffer();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
     }
@@ -278,45 +279,49 @@
     public void startAudio() {
         try {
             mState = STATE_STARTED;
-            for (StreamConfigurationView configView : mStreamConfigurationViews) {
-                mAudioStreamTester.start();
+            for (StreamContext streamContext : mStreamContexts) {
+                StreamConfigurationView configView = streamContext.configurationView;
+                streamContext.tester.start();
                 configView.updateDisplay();
             }
             updateEnabledWidgets();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
     }
 
     public void pauseAudio() {
         try {
-            mAudioStreamTester.pause();
+            for (StreamContext streamContext : mStreamContexts) {
+                streamContext.tester.pause();
+            }
             mState = STATE_PAUSED;
             updateEnabledWidgets();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
     }
 
     public void stopAudio() {
         try {
-            mAudioStreamTester.stop();
+            for (StreamContext streamContext : mStreamContexts) {
+                streamContext.tester.stop();
+            }
             mState = STATE_STOPPED;
             updateEnabledWidgets();
         } catch (Exception e) {
             e.printStackTrace();
-            mStatusView.setText(e.getMessage());
             showToast(e.getMessage());
         }
     }
 
     public void closeAudio() {
         mStreamSniffer.stopStreamSniffer();
-        mAudioStreamTester.close();
+        for (StreamContext streamContext : mStreamContexts) {
+            streamContext.tester.close();
+        }
         mState = STATE_CLOSED;
         updateEnabledWidgets();
     }
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestInputActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestInputActivity.java
index 924c440..c56e11c 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestInputActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestInputActivity.java
@@ -36,12 +36,13 @@
         implements ActivityCompat.OnRequestPermissionsResultCallback {
 
     private static final int AUDIO_ECHO_REQUEST = 0;
-    private AudioInputTester mAudioInputTester;
+    protected AudioInputTester mAudioInputTester;
     private static final int NUM_VOLUME_BARS = 4;
     private VolumeBarView[] mVolumeBars = new VolumeBarView[NUM_VOLUME_BARS];
 
     @Override boolean isOutput() { return false; }
 
+    @Override
     protected void inflateActivity() {
         setContentView(R.layout.activity_test_input);
     }
@@ -49,19 +50,18 @@
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-        inflateActivity();
 
         mVolumeBars[0] = (VolumeBarView) findViewById(R.id.volumeBar0);
         mVolumeBars[1] = (VolumeBarView) findViewById(R.id.volumeBar1);
         mVolumeBars[2] = (VolumeBarView) findViewById(R.id.volumeBar2);
         mVolumeBars[3] = (VolumeBarView) findViewById(R.id.volumeBar3);
 
-        findAudioCommon();
         updateEnabledWidgets();
 
-        mAudioStreamTester = mAudioInputTester = AudioInputTester.getInstance();
+        mAudioInputTester = addAudioInputTester();
     }
 
+    @Override
     void updateStreamDisplay() {
         int numChannels = mAudioInputTester.getCurrentAudioStream().getChannelCount();
         if (numChannels > NUM_VOLUME_BARS) {
@@ -73,6 +73,7 @@
         }
     }
 
+    @Override
     public void openAudio() {
         if (!isRecordPermissionGranted()){
             requestRecordPermission();
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivity.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivity.java
index 342a429..c3ae265 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivity.java
@@ -29,14 +29,17 @@
     private CheckBox[] mChannelBoxes;
 
     @Override
+    protected void inflateActivity() {
+        setContentView(R.layout.activity_test_output);
+    }
+
+    @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-        setContentView(R.layout.activity_test_output);
 
-        findAudioCommon();
         updateEnabledWidgets();
 
-        mAudioStreamTester = mAudioOutTester = AudioOutputTester.getInstance();
+        mAudioOutTester = addAudioOutputTester();
 
         mChannelBoxes = new CheckBox[MAX_CHANNEL_BOXES];
         int ic = 0;
diff --git a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivityBase.java b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivityBase.java
index 60917d7..1c162d8 100644
--- a/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivityBase.java
+++ b/apps/OboeTester/app/src/main/java/com/google/sample/oboe/manualtest/TestOutputActivityBase.java
@@ -25,7 +25,7 @@
 import com.google.sample.oboe.manualtest.R;
 
 
-class TestOutputActivityBase extends TestAudioActivity {
+abstract class TestOutputActivityBase extends TestAudioActivity {
     AudioOutputTester mAudioOutTester;
 
     protected TextView mTextThreshold;
@@ -39,9 +39,11 @@
 
     protected void updateEnabledWidgets() {
         super.updateEnabledWidgets();
-        boolean thresholdSupported = (mAudioStreamTester == null) ? false :
-                mAudioStreamTester.getCurrentAudioStream().isThresholdSupported();
-        mFaderThreshold.setEnabled(getState() == STATE_STARTED && thresholdSupported);
+        for (StreamContext streamContext : mStreamContexts) {
+            boolean thresholdSupported = (streamContext.tester == null) ? false :
+                    streamContext.tester.getCurrentAudioStream().isThresholdSupported();
+            mFaderThreshold.setEnabled(getState() == STATE_STARTED && thresholdSupported);
+        }
     }
 
     private SeekBar.OnSeekBarChangeListener mAmplitudeListener = new SeekBar.OnSeekBarChangeListener() {
@@ -80,11 +82,11 @@
         double normalizedThreshold = mTaperThreshold.linearToExponential(progress);
         if (normalizedThreshold < 0.0) normalizedThreshold = 0.0;
         else if (normalizedThreshold > 1.0) normalizedThreshold = 1.0;
-        if (mAudioStreamTester != null) {
+        if (mAudioOutTester != null) {
             mAudioOutTester.setNormalizedThreshold(normalizedThreshold);
             int percent = (int) (normalizedThreshold * 100);
-            int bufferSize = mAudioStreamTester.getCurrentAudioStream().getBufferSizeInFrames();
-            int bufferCapacity = mAudioStreamTester.getCurrentAudioStream().getBufferCapacityInFrames();
+            int bufferSize = mAudioOutTester.getCurrentAudioStream().getBufferSizeInFrames();
+            int bufferCapacity = mAudioOutTester.getCurrentAudioStream().getBufferCapacityInFrames();
             mTextThreshold.setText("bufferSize = " + percent + "% = "
                     + bufferSize + " / " + bufferCapacity);
         }
diff --git a/apps/OboeTester/app/src/main/res/layout/activity_echo.xml b/apps/OboeTester/app/src/main/res/layout/activity_echo.xml
index 5b484d8..3934c32 100644
--- a/apps/OboeTester/app/src/main/res/layout/activity_echo.xml
+++ b/apps/OboeTester/app/src/main/res/layout/activity_echo.xml
@@ -11,6 +11,13 @@
     tools:context="com.google.sample.oboe.manualtest.EchoActivity">
 
     <com.google.sample.oboe.manualtest.StreamConfigurationView
+        android:id="@+id/inputStreamConfiguration"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="center"
+        android:orientation="horizontal" />
+
+    <com.google.sample.oboe.manualtest.StreamConfigurationView
         android:id="@+id/outputStreamConfiguration"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
@@ -27,7 +34,7 @@
             android:layout_width="0dp"
             android:layout_weight="1"
             android:layout_height="wrap_content"
-            android:onClick="onStartPlayback"
+            android:onClick="onStartEcho"
             android:text="@string/playAudio" />
 
         <Button
@@ -35,7 +42,7 @@
             android:layout_width="0dp"
             android:layout_weight="1"
             android:layout_height="wrap_content"
-            android:onClick="onStopRecordPlay"
+            android:onClick="onStopEcho"
             android:text="@string/stopAudio" />
 
 
diff --git a/apps/OboeTester/app/src/main/res/layout/activity_main.xml b/apps/OboeTester/app/src/main/res/layout/activity_main.xml
index 0d5cacb..fb88034 100644
--- a/apps/OboeTester/app/src/main/res/layout/activity_main.xml
+++ b/apps/OboeTester/app/src/main/res/layout/activity_main.xml
@@ -44,6 +44,13 @@
         android:onClick="onLaunchRecorder"
         android:text="Record and Play" />
 
+    <Button
+        android:id="@+id/buttonEcho"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:onClick="onLaunchEcho"
+        android:text="Echo Input to Output" />
+
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
diff --git a/apps/OboeTester/app/src/main/res/layout/activity_recorder.xml b/apps/OboeTester/app/src/main/res/layout/activity_recorder.xml
index 956b97e..7527fa0 100644
--- a/apps/OboeTester/app/src/main/res/layout/activity_recorder.xml
+++ b/apps/OboeTester/app/src/main/res/layout/activity_recorder.xml
@@ -11,7 +11,7 @@
     tools:context="com.google.sample.oboe.manualtest.RecorderActivity">
 
     <com.google.sample.oboe.manualtest.StreamConfigurationView
-        android:id="@+id/outputStreamConfiguration"
+        android:id="@+id/streamConfiguration"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:gravity="center"
diff --git a/apps/OboeTester/app/src/main/res/layout/merge_audio_common.xml b/apps/OboeTester/app/src/main/res/layout/merge_audio_common.xml
index a6bf243..15812ea 100644
--- a/apps/OboeTester/app/src/main/res/layout/merge_audio_common.xml
+++ b/apps/OboeTester/app/src/main/res/layout/merge_audio_common.xml
@@ -3,7 +3,7 @@
     android:layout_width="match_parent" android:layout_height="match_parent">
 
     <com.google.sample.oboe.manualtest.StreamConfigurationView
-        android:id="@+id/outputStreamConfiguration"
+        android:id="@+id/streamConfiguration"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:gravity="center"
@@ -100,11 +100,4 @@
         android:layout_height="wrap_content"
         android:text="callback returns STOP" />
 
-    <TextView
-        android:id="@+id/statusView"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:lines="3"
-        android:text="@string/init_status" />
-
 </merge>
diff --git a/apps/OboeTester/app/src/main/res/layout/stream_config.xml b/apps/OboeTester/app/src/main/res/layout/stream_config.xml
index 2f3ebbd..b0d3c9f 100644
--- a/apps/OboeTester/app/src/main/res/layout/stream_config.xml
+++ b/apps/OboeTester/app/src/main/res/layout/stream_config.xml
@@ -198,6 +198,12 @@
             android:layout_height="wrap_content"
             android:text="info:" />
 
+        <TextView
+            android:id="@+id/statusView"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:lines="3"
+            android:text="@string/init_status" />
     </LinearLayout>
 
 </merge>
diff --git a/apps/OboeTester/app/src/main/res/values/strings.xml b/apps/OboeTester/app/src/main/res/values/strings.xml
index b052a5c..81c81aa 100644
--- a/apps/OboeTester/app/src/main/res/values/strings.xml
+++ b/apps/OboeTester/app/src/main/res/values/strings.xml
@@ -86,6 +86,7 @@
     <string name="title_activity_test_input">Test Input</string>
     <string name="title_activity_latency">Tap to Tone</string>
     <string name="title_activity_recorder">Recorder</string>
+    <string name="title_activity_echo">Echo Input to Output</string>
 
     <string name="need_record_audio_permission">"This app needs RECORD_AUDIO permission"</string>